16 Commits

Author SHA1 Message Date
MatrixSeven
1e16e30681 feat: 改进连接状态检查逻辑,优化文件传输和请求文件功能 2025-09-19 18:19:25 +08:00
MatrixSeven
08f9d50e66 feat:桌面共享UI/逻辑优化 2025-09-18 18:43:54 +08:00
MatrixSeven
2fc478e889 feat: ws实现 2025-09-16 16:41:38 +08:00
MatrixSeven
15d23de5a7 feat: 优化文件传输逻辑,添加断开连接回调和清除发送方数据功能
- 使用更宽松的条件检查连接状态,确保文件列表和文件请求的发送时机
- 添加清除发送方数据的逻辑,当接收方离开房间时重置文件传输状态
- 在文件传输业务中设置断开连接回调,确保在连接断开时清理相关数据
- 更新数据通道和P2P连接状态的处理,增强连接状态的监控和反馈
2025-09-15 19:39:57 +08:00
MatrixSeven
550be8bcc6 feat:降级 2025-09-15 18:28:16 +08:00
MatrixSeven
50d30f23bf feat: 移除WebRTC房间状态API,优化路由设置 2025-09-11 15:55:10 +08:00
MatrixSeven
4b31e76488 feat: 添加文件传输速度和剩余时间计算,优化文件传输进度显示 2025-09-10 16:49:18 +08:00
MatrixSeven
84d7caea8c feat: 添加文件传输服务器配置和路由设置,支持外部前端目录|添加文件传输速度计算 2025-09-10 16:48:21 +08:00
MatrixSeven
343e7f1192 feat: 更新ICE服务器配置,添加默认服务器标记,优化删除提示信息 2025-09-10 15:05:28 +08:00
MatrixSeven
07409abb3b feat:UI更新 2025-09-08 10:45:35 +08:00
MatrixSeven
1e5d74433b feat:自定义turn更新|链接恢复机制|帮助页面添加 2025-09-05 17:12:22 +08:00
MatrixSeven
8e4c42bbbe feat:issue模板添加 2025-09-03 10:46:24 +08:00
MatrixSeven
6d5b4329db feat:issue模板添加 2025-09-03 10:45:04 +08:00
MatrixSeven
dfa225e68e feat: 删除不再使用的构建脚本和模型文件 2025-09-02 16:30:33 +08:00
MatrixSeven
4faf1c3141 feat:共享桌面移动端全屏支持 2025-09-01 15:53:03 +08:00
MatrixSeven
86fd9ec08c feat: 合并effect,优化逻辑 2025-08-29 18:19:59 +08:00
84 changed files with 7659 additions and 3958 deletions

12
.chuan.env Normal file
View File

@@ -0,0 +1,12 @@
# 文件传输服务器配置
# 主服务器配置
PORT=8080
# FRONTEND_DIR=./dist
# TURN服务器配置
TURN_ENABLED=true
TURN_PORT=3478
TURN_USERNAME=chuan
TURN_PASSWORD=chuan123
TURN_REALM=localhost

23
.chuan.env.example Normal file
View File

@@ -0,0 +1,23 @@
# 文件传输服务器配置文件
# 这个文件会被自动加载,支持 KEY=VALUE 格式
# 服务器端口
PORT=8080
# 外部前端文件目录 (可选)
# 如果设置了这个路径,服务器会使用指定目录的前端文件
# 而不是内嵌在二进制文件中的前端文件
# FRONTEND_DIR=./chuan-next/out
# FRONTEND_DIR=/var/www/chuan-frontend
# 示例: Docker 容器内的路径
# FRONTEND_DIR=/app/frontend
# 示例: 开发环境
# FRONTEND_DIR=./chuan-next/dist
# 注意:
# 1. 环境变量的优先级高于配置文件
# 2. 命令行参数的优先级最高
# 3. 空行和以 # 开头的行会被忽略
# 4. 值可以用单引号或双引号包围

95
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: 🐛 Bug 报告
description: 报告一个错误或问题
title: "[Bug] "
labels: ["bug", "需要调查"]
body:
- type: markdown
attributes:
value: |
感谢您花时间填写错误报告!请详细描述遇到的问题,这将帮助我们更快地定位和解决问题。
- type: textarea
id: problem-description
attributes:
label: 问题描述
description: 请详细描述您遇到的问题
placeholder: |
例如:在文件传输过程中,当传输大文件时连接会意外断开...
validations:
required: true
- type: dropdown
id: deployment-environment
attributes:
label: 部署环境
description: 您使用的是什么部署方式?
options:
- 二进制部署(下载发布的可执行文件)
- 自行构建(从源码编译)
- Docker 部署
- 官方演示站
- 其他(请在详细信息中说明)
validations:
required: true
- type: textarea
id: environment-details
attributes:
label: 环境详细信息
description: 请提供环境相关信息
placeholder: |
- 操作系统Linux Ubuntu 20.04 / Windows 10 / macOS 12.x
- 浏览器Chrome 120.x / Firefox 121.x / Safari 17.x
- 网络环境:局域网 / 公网 / NAT环境
- 设备类型PC / 移动设备
- 其他相关信息...
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: 复现步骤
description: 请描述如何复现这个问题
placeholder: |
1. 打开应用
2. 点击 '文件传输'
3. 选择一个大文件 (>100MB)
4. 点击发送
5. 观察到连接断开...
validations:
required: true
- type: textarea
id: logs
attributes:
label: 相关日志
description: |
请提供相关的错误日志、控制台输出或服务器日志
提示:您可以在浏览器开发者工具的控制台中查看客户端日志
render: text
placeholder: |
[2024-01-15 10:30:45] [ERROR] WebRTC连接失败: ICE连接超时
[2024-01-15 10:30:45] [INFO] 尝试重新连接...
或者粘贴浏览器控制台的错误信息...
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: |
如果适用,请添加截图或录屏来帮助解释您的问题
您可以直接拖拽图片到这个文本框中
placeholder: 拖拽图片文件到这里,或者粘贴图片链接
- type: textarea
id: additional-context
attributes:
label: 其他信息
description: 任何其他可能有助于解决问题的信息
placeholder: |
- 问题发生的频率:每次 / 偶尔 / 特定条件下
- 是否在多个设备上都出现
- 最近是否有环境变化
- 其他可能相关的信息...

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: 📚 项目文档
url: https://github.com/MatrixSeven/file-transfer-go/blob/main/README.md
about: 查看项目使用文档和部署指南
- name: 💬 讨论区
url: https://github.com/MatrixSeven/file-transfer-go/discussions
about: 参与社区讨论,分享使用经验和想法
- name: 🌐 官方演示
url: https://transfer.52python.cn/
about: 访问官方演示站点体验功能(如果可用)

View File

@@ -0,0 +1,72 @@
name: ✨ 功能请求
description: 建议一个新功能或改进
title: "[Feature] "
labels: ["enhancement", "功能请求"]
body:
- type: markdown
attributes:
value: |
感谢您提出功能建议!您的想法对改进项目非常重要。请详细描述您的建议。
- type: textarea
id: feature-description
attributes:
label: 建议功能
description: 请清楚简洁地描述您希望实现的功能
placeholder: |
例如:希望添加文件加密传输功能,在传输过程中对文件进行端到端加密...
validations:
required: true
- type: textarea
id: motivation
attributes:
label: 需求原因
description: 请解释为什么需要这个功能,它解决了什么问题?
placeholder: |
例如:
- 当前在传输敏感文件时缺乏安全保障
- 在公网环境下传输文件可能被第三方截获
- 企业用户需要确保数据传输的安全性
validations:
required: true
- type: textarea
id: use-cases
attributes:
label: 使用场景
description: 请描述这个功能的具体使用场景和出发点
placeholder: |
例如:
- 医疗机构传输患者档案时需要加密保护
- 企业内部传输财务报表等敏感文档
- 个人用户传输私人照片和视频时希望保护隐私
- 在不受信任的网络环境下进行文件传输
validations:
required: true
- type: dropdown
id: priority
attributes:
label: 优先级
description: 您认为这个功能的优先级如何?
options:
- Nice to have
- 中(重要但不紧急)
- 高(对用户体验很重要)
- 关键(阻碍正常使用)
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: 其他信息
description: 任何其他可能有助于理解和实现这个功能的信息
placeholder: |
- 类似功能的参考应用或网站
- 相关技术文档或标准
- 社区讨论链接
- 其他补充说明

54
.github/ISSUE_TEMPLATE/question.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: 💬 问题咨询
description: 使用问题、配置疑问或一般性讨论
title: "[Question] "
labels: ["question", "需要回复"]
body:
- type: markdown
attributes:
value: |
如果您有使用问题、配置疑问或想要讨论项目相关话题,请使用这个模板。
- type: dropdown
id: question-type
attributes:
label: 问题类型
description: 请选择您的问题类型
options:
- 使用问题(如何使用某个功能)
- 配置问题(部署和设置相关)
- 技术咨询(技术实现相关)
- 功能理解(不确定某个功能如何工作)
- 其他
validations:
required: true
- type: textarea
id: question
attributes:
label: 具体问题
description: 请详细描述您的问题
placeholder: |
例如我想在内网环境下部署这个应用但是不知道如何配置STUN服务器...
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 如果相关,请提供环境信息
placeholder: |
- 操作系统:
- 部署方式:
- 网络环境:
- 浏览器版本:
- type: checkboxes
id: checklist
attributes:
label: 确认事项
options:
- label: 我已经查看了项目文档和README
required: true
- label: 我已经搜索了现有的Issues
required: true

View File

@@ -3,7 +3,7 @@
**安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用**
## [在线体验](https://transfer.52python.cn) • [GitHub](https://github.com/MatrixSeven/file-transfer-go)
## [在线体验](https://transfer.52python.cn) • [关注我](https://x.com/_MatrixSeven) • [帮助文档](https://transfer.52python.cn/help)
![项目演示](img.png)
@@ -31,6 +31,15 @@
## 🔄 最近更新日志
### 2025-09-5
-**WEBRTC链接恢复** - 关闭页面后在打开,进行数据链接恢复
-**定义TURN配置** - 支持自定义中继TURN配置
-**优化移动端提示** - 优化各种场景的错误提示
-**帮助文档** - 常见问题说明文档更新
### 2025-09-1
-**移动端桌面全屏** - 优化移动端下UI并解决全屏问题
### 2025-08-28
-**完善Docker部署支持** - 优化Docker配置支持一键部署和多环境配置
-**优化README文档** - 更新项目说明,完善部署指南和技术栈信息

37
_deploy.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
set -e
echo "🚀 构建并部署前端..."
# 构建前端
cd chuan-next
npm run build:ssg
cd ..
# 压缩
tar -czf /tmp/frontend.tar.gz -C chuan-next/out .
# 创建服务器目录并上传
ssh root@101.33.214.22 "mkdir -p /root/file-transfer/chuan-next"
scp /tmp/frontend.tar.gz root@101.33.214.22:/root/file-transfer/chuan-next/
ssh root@101.33.214.22 << 'EOF'
cd /root/file-transfer/chuan-next
# 备份 api 目录
[ -d current/api ] && cp -r current/api /tmp/api-backup
# 解压新版本
rm -rf current
mkdir current
cd current
tar -xzf ../frontend.tar.gz
# 还原 api 目录
[ -d /tmp/api-backup ] && cp -r /tmp/api-backup ./api && rm -rf /tmp/api-backup
# 清理压缩包
rm -f ../frontend.tar.gz
EOF
# 清理本地文件
rm -f /tmp/frontend.tar.gz
rm -rf chuan-next/out
echo "✅ 部署完成"

View File

@@ -1,87 +0,0 @@
#!/bin/bash
# =============================================================================
# 简化版 SSG 构建脚本
# 专注于 API 路由的处理和静态导出
# =============================================================================
set -e
# 颜色定义
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
# 配置
PROJECT_ROOT="$(pwd)"
API_DIR="$PROJECT_ROOT/src/app/api"
TEMP_API_DIR="/tmp/nextjs-api-$(date +%s)"
echo -e "${GREEN}🚀 开始 SSG 静态导出构建...${NC}"
# 错误处理函数
cleanup_on_error() {
echo -e "${RED}❌ 构建失败,正在恢复文件...${NC}"
if [ -d "$TEMP_API_DIR" ]; then
if [ -d "$TEMP_API_DIR/api" ]; then
mv "$TEMP_API_DIR/api" "$API_DIR" 2>/dev/null || true
echo -e "${YELLOW}📂 已恢复 API 目录${NC}"
fi
rm -rf "$TEMP_API_DIR"
fi
exit 1
}
# 设置错误处理
trap cleanup_on_error ERR INT TERM
# 步骤 1: 备份 API 路由
if [ -d "$API_DIR" ]; then
echo -e "${YELLOW}📦 备份 API 路由...${NC}"
mkdir -p "$TEMP_API_DIR"
mv "$API_DIR" "$TEMP_API_DIR/"
echo "✅ API 路由已备份到临时目录"
else
echo -e "${YELLOW}⚠️ API 目录不存在,跳过备份${NC}"
fi
# 步骤 2: 清理构建文件
echo -e "${YELLOW}🧹 清理之前的构建...${NC}"
rm -rf .next out
# 步骤 3: 执行静态构建
echo -e "${YELLOW}🔨 执行静态导出构建...${NC}"
NEXT_EXPORT=true yarn build
# 步骤 4: 验证构建结果
if [ -d "out" ] && [ -f "out/index.html" ]; then
echo -e "${GREEN}✅ 静态导出构建成功!${NC}"
# 显示构建统计
file_count=$(find out -type f | wc -l)
dir_size=$(du -sh out | cut -f1)
echo "📊 构建统计:"
echo " - 文件数量: $file_count"
echo " - 总大小: $dir_size"
else
echo -e "${RED}❌ 构建验证失败${NC}"
cleanup_on_error
fi
# 步骤 5: 恢复 API 路由
if [ -d "$TEMP_API_DIR/api" ]; then
echo -e "${YELLOW}🔄 恢复 API 路由...${NC}"
mv "$TEMP_API_DIR/api" "$API_DIR"
echo "✅ API 路由已恢复"
fi
# 步骤 6: 清理临时文件
rm -rf "$TEMP_API_DIR"
echo ""
echo -e "${GREEN}🎉 SSG 构建完成!${NC}"
echo -e "${GREEN}📁 静态文件位于: ./out/${NC}"
echo -e "${GREEN}🚀 部署命令: npx serve out${NC}"
echo ""
echo -e "${YELLOW}💡 提示: 静态版本会直接连接到 Go 后端 (localhost:8080)${NC}"

0
chuan-next/build-static.sh Normal file → Executable file
View File

View File

@@ -1,29 +1,32 @@
"use client";
import React, { useEffect, useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Upload, MessageSquare, Monitor, Users } from 'lucide-react';
import Hero from '@/components/Hero';
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer';
import DesktopShare from '@/components/DesktopShare';
import Footer from '@/components/Footer';
import Hero from '@/components/Hero';
import WeChatGroup from '@/components/WeChatGroup';
import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
import WebRTCSettings from '@/components/WebRTCSettings';
import { WebRTCTextImageTransfer } from '@/components/WebRTCTextImageTransfer';
import { WebRTCUnsupportedModal } from '@/components/WebRTCUnsupportedModal';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useWebRTCSupport } from '@/hooks/connection';
import { useTabNavigation, TabType } from '@/hooks/ui';
import { useWebRTCConfigSync } from '@/hooks/settings';
import { TabType, useTabNavigation } from '@/hooks/ui';
import { MessageSquare, Monitor, Settings, Upload, Users } from 'lucide-react';
export default function HomePage() {
// WebRTC配置同步
useWebRTCConfigSync();
// 使用tab导航hook
const {
activeTab,
handleTabChange,
getConnectionInfo,
hasInitialized,
const {
activeTab,
handleTabChange,
confirmDialogState,
closeConfirmDialog
} = useTabNavigation();
// WebRTC 支持检测
const {
webrtcSupport,
@@ -34,18 +37,6 @@ export default function HomePage() {
showUnsupportedModalManually,
} = useWebRTCSupport();
// 桌面共享功能的占位符函数(保持向后兼容)
const handleStartSharing = async () => {
console.log('开始桌面共享');
};
const handleStopSharing = async () => {
console.log('停止桌面共享');
};
const handleJoinSharing = async (code: string) => {
console.log('加入桌面共享:', code);
};
// 处理Tabs组件的字符串参数
const handleTabChangeWrapper = (value: string) => {
@@ -54,122 +45,147 @@ export default function HomePage() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
<div className="container mx-auto px-4 py-4 sm:py-6 md:py-8">
{/* Hero Section */}
<div className="text-center mb-6 sm:mb-8">
<Hero />
</div>
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex flex-col">
<div className="flex-1">
<div className="container mx-auto px-4 py-2 sm:py-4 md:py-6">
{/* Hero Section */}
<div className="text-center mb-4 sm:mb-6">
<Hero />
</div>
{/* WebRTC 支持检测加载状态 */}
{!isChecked && (
<div className="max-w-4xl mx-auto text-center py-8">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-48 mx-auto mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
{/* WebRTC 支持检测加载状态 */}
{!isChecked && (
<div className="max-w-4xl mx-auto text-center py-8">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-48 mx-auto mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
</div>
<p className="mt-4 text-gray-600">...</p>
</div>
<p className="mt-4 text-gray-600">...</p>
</div>
)}
)}
{/* 主要内容 - 只有在检测完成后才显示 */}
{isChecked && (
<div className="max-w-4xl mx-auto">
{/* WebRTC 不支持时的警告横幅 */}
{!isSupported && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
<span className="text-red-700 font-medium">
WebRTC使
</span>
{/* 主要内容 - 只有在检测完成后才显示 */}
{isChecked && (
<div className="max-w-4xl mx-auto">
{/* WebRTC 不支持时的警告横幅 */}
{!isSupported && (
<div className="mb-6 p-6 bg-gradient-to-r from-rose-50 via-orange-50 to-amber-50 border border-orange-200 rounded-xl shadow-sm backdrop-blur-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-3 h-3 bg-gradient-to-r from-orange-400 to-red-500 rounded-full animate-pulse shadow-lg"></div>
<div className="absolute inset-0 w-3 h-3 bg-gradient-to-r from-orange-400 to-red-500 rounded-full animate-ping opacity-30"></div>
</div>
<div className="flex flex-col">
<span className="text-orange-800 font-semibold text-sm">
</span>
<span className="text-orange-700 text-sm">
WebRTC使
</span>
</div>
</div>
<button
onClick={showUnsupportedModalManually}
className="px-4 py-2 bg-gradient-to-r from-orange-500 to-red-500 text-white text-sm font-medium rounded-lg hover:from-orange-600 hover:to-red-600 transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
</button>
</div>
<button
onClick={showUnsupportedModalManually}
className="text-red-600 hover:text-red-800 text-sm underline"
>
</button>
</div>
</div>
)}
)}
<Tabs value={activeTab} onValueChange={handleTabChangeWrapper} className="w-full">
{/* Tabs Navigation - 横向布局 */}
<div className="mb-6">
<TabsList className="grid w-full grid-cols-4 max-w-2xl mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
<TabsTrigger
value="webrtc"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
disabled={!isSupported}
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
{!isSupported && <span className="text-xs opacity-60">*</span>}
</TabsTrigger>
<TabsTrigger
value="message"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-emerald-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-emerald-600"
disabled={!isSupported}
>
<MessageSquare className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
{!isSupported && <span className="text-xs opacity-60">*</span>}
</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"
disabled={!isSupported}
>
<Monitor className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
{!isSupported && <span className="text-xs opacity-60">*</span>}
</TabsTrigger>
<TabsTrigger
value="wechat"
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-green-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-green-600"
>
<Users className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
</TabsList>
{/* WebRTC 不支持时的提示 */}
{!isSupported && (
<p className="text-center text-xs text-gray-500 mt-2">
* WebRTC 使
</p>
)}
</div>
<Tabs value={activeTab} onValueChange={handleTabChangeWrapper} className="w-full">
{/* Tabs Navigation - 横向布局 */}
<div className="mb-6">
<TabsList className="grid w-full grid-cols-5 max-w-3xl mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
<TabsTrigger
value="webrtc"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
disabled={!isSupported}
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
{!isSupported && <span className="text-xs opacity-60">*</span>}
</TabsTrigger>
<TabsTrigger
value="message"
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-emerald-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-emerald-600"
disabled={!isSupported}
>
<MessageSquare className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
{!isSupported && <span className="text-xs opacity-60">*</span>}
</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"
disabled={!isSupported}
>
<Monitor className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
{!isSupported && <span className="text-xs opacity-60">*</span>}
</TabsTrigger>
<TabsTrigger
value="wechat"
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-green-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-green-600"
>
<Users className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger
value="settings"
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-orange-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-orange-600"
>
<Settings className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
</TabsList>
{/* Tab Content */}
<div>
<TabsContent value="webrtc" className="mt-0 animate-fade-in-up">
<WebRTCFileTransfer />
</TabsContent>
{/* WebRTC 不支持时的提示 */}
{!isSupported && (
<p className="text-center text-xs text-gray-500 mt-2">
* WebRTC 使
</p>
)}
</div>
<TabsContent value="message" className="mt-0 animate-fade-in-up">
<WebRTCTextImageTransfer />
</TabsContent>
{/* Tab Content */}
<div>
<TabsContent value="webrtc" className="mt-0 animate-fade-in-up">
<WebRTCFileTransfer />
</TabsContent>
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
<DesktopShare />
</TabsContent>
<TabsContent value="message" className="mt-0 animate-fade-in-up">
<WebRTCTextImageTransfer />
</TabsContent>
<TabsContent value="wechat" className="mt-0 animate-fade-in-up">
<WeChatGroup />
</TabsContent>
</div>
</Tabs>
</div>
)}
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
<DesktopShare />
</TabsContent>
<TabsContent value="wechat" className="mt-0 animate-fade-in-up">
<WeChatGroup />
</TabsContent>
<TabsContent value="settings" className="mt-0 animate-fade-in-up">
<WebRTCSettings />
</TabsContent>
</div>
</Tabs>
</div>
)}
</div>
</div>
{/* 页脚 */}
<Footer />
{/* WebRTC 不支持提示模态框 */}
{webrtcSupport && (
<WebRTCUnsupportedModal

View File

@@ -86,7 +86,6 @@ body {
color: hsl(var(--foreground));
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow-x: hidden;
overflow-y: hidden;
min-height: 100vh;
margin: 0;
padding: 0;

View File

@@ -0,0 +1,761 @@
"use client";
import React, { useState, useEffect } from 'react';
import {
Book,
Server,
Download,
Code,
Container,
Globe,
ChevronRight,
ExternalLink,
Copy,
Check,
AlertTriangle,
Info,
Lightbulb,
HelpCircle,
Upload,
MessageSquare,
Monitor,
Settings,
Shield,
Smartphone,
Wifi,
Users,
Home,
ArrowLeft
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
interface SectionProps {
id: string;
title: string;
icon: React.ReactNode;
children: React.ReactNode;
}
function Section({ id, title, icon, children }: SectionProps) {
return (
<section id={id} className="mb-6 scroll-mt-16 lg:scroll-mt-20">
<div className="flex items-center gap-3 mb-4 lg:mb-6">
<div className="p-2 lg:p-3 bg-blue-100 rounded-lg">
{icon}
</div>
<h2 className="text-xl lg:text-2xl font-bold text-gray-900">{title}</h2>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 lg:p-6">
{children}
</div>
</section>
);
}
interface CodeBlockProps {
code: string;
language?: string;
}
function CodeBlock({ code, language = "bash" }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('复制失败:', error);
}
};
return (
<div className="relative">
<div className="bg-gray-900 rounded-lg p-4 overflow-x-auto">
<pre className="text-green-400 text-sm font-mono">
<code>{code}</code>
</pre>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="absolute top-2 right-2 text-gray-400 hover:text-white"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
);
}
interface InfoBoxProps {
type: 'info' | 'warning' | 'tip' | 'error';
title: string;
children: React.ReactNode;
}
function InfoBox({ type, title, children }: InfoBoxProps) {
const styles = {
info: {
bg: 'bg-blue-50',
border: 'border-blue-200',
icon: <Info className="w-5 h-5 text-blue-600" />,
titleColor: 'text-blue-900'
},
warning: {
bg: 'bg-yellow-50',
border: 'border-yellow-200',
icon: <AlertTriangle className="w-5 h-5 text-yellow-600" />,
titleColor: 'text-yellow-900'
},
tip: {
bg: 'bg-green-50',
border: 'border-green-200',
icon: <Lightbulb className="w-5 h-5 text-green-600" />,
titleColor: 'text-green-900'
},
error: {
bg: 'bg-red-50',
border: 'border-red-200',
icon: <AlertTriangle className="w-5 h-5 text-red-600" />,
titleColor: 'text-red-900'
}
};
const style = styles[type];
return (
<div className={`${style.bg} ${style.border} border rounded-lg p-4 my-4`}>
<div className="flex items-start gap-3">
{style.icon}
<div className="flex-1">
<h4 className={`font-semibold ${style.titleColor} mb-2`}>{title}</h4>
<div className="text-sm text-gray-700">{children}</div>
</div>
</div>
</div>
);
}
export default function HelpPage() {
const [activeSection, setActiveSection] = useState('deployment');
const [sidebarLeft, setSidebarLeft] = useState(0);
const sections = [
{
id: 'deployment',
title: '部署指南',
icon: <Server className="w-5 h-5 text-blue-600" />,
children: [
{ id: 'docker-deployment', title: 'Docker 部署', icon: <Container className="w-4 h-4 text-blue-500" /> },
{ id: 'binary-deployment', title: '二进制部署', icon: <Download className="w-4 h-4 text-green-500" /> },
{ id: 'build-deployment', title: '自行构建', icon: <Code className="w-4 h-4 text-purple-500" /> },
]
},
{ id: 'desktop-share', title: '桌面共享权限问题', icon: <Monitor className="w-5 h-5 text-blue-600" /> },
{ id: 'port-config', title: '自定义端口配置', icon: <Settings className="w-5 h-5 text-blue-600" /> },
{ id: 'security', title: '全局域网部署', icon: <Shield className="w-5 h-5 text-blue-600" /> },
{ id: 'data-transfer', title: '数据传输机制', icon: <Wifi className="w-5 h-5 text-blue-600" /> },
{ id: 'contact', title: '交流反馈群', icon: <Users className="w-5 h-5 text-blue-600" /> },
];
const scrollToSection = (sectionId: string) => {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
setActiveSection(sectionId);
// 更新 URL hash
window.history.pushState(null, '', `#${sectionId}`);
}
};
// 初始化时检查 URL hash 并滚动到对应位置
useEffect(() => {
const hash = window.location.hash.replace('#', '');
if (hash) {
// 延迟一下确保 DOM 已经渲染完成
setTimeout(() => {
const element = document.getElementById(hash);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
setActiveSection(hash);
}
}, 100);
}
}, []);
// 监听滚动事件来更新活跃的章节和 URL hash
useEffect(() => {
const handleScroll = () => {
const scrollPosition = window.scrollY + 100;
// 检查所有可能的section ID包括子目录
const allSectionIds = sections.reduce<string[]>((acc, section) => {
acc.push(section.id);
if (section.children) {
acc.push(...section.children.map(child => child.id));
}
return acc;
}, []);
for (const sectionId of allSectionIds) {
const element = document.getElementById(sectionId);
if (element) {
const { offsetTop, offsetHeight } = element;
if (scrollPosition >= offsetTop && scrollPosition < offsetTop + offsetHeight) {
setActiveSection(sectionId);
// 更新 URL hash但不触发页面滚动
if (window.location.hash !== `#${sectionId}`) {
window.history.replaceState(null, '', `#${sectionId}`);
}
break;
}
}
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [sections]);
// 计算侧边栏的位置
useEffect(() => {
const updateSidebarPosition = () => {
const container = document.querySelector('.w-\\[95\\%\\]');
if (container) {
const containerRect = container.getBoundingClientRect();
const containerLeft = containerRect.left;
// 计算第一列的位置24px padding + grid gap
setSidebarLeft(containerLeft + 24);
}
};
updateSidebarPosition();
window.addEventListener('resize', updateSidebarPosition);
return () => window.removeEventListener('resize', updateSidebarPosition);
}, []);
return (
<div className="w-[95%] lg:w-[70%] max-w-none mx-auto p-4 lg:p-6">
{/* Header */}
<div className="text-center mb-8 lg:mb-12">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="p-3 bg-blue-100 rounded-xl">
<Book className="w-8 h-8 text-blue-600" />
</div>
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900">使</h1>
</div>
<p className="text-base lg:text-lg text-gray-600 max-w-2xl mx-auto">
使
</p>
</div>
<div className="relative">
{/* 返回首页按钮 - 桌面端固定定位 */}
<div className="hidden lg:block">
<div
className="fixed bg-white rounded-xl shadow-lg border border-gray-200 p-4 z-20"
style={{ left: `${sidebarLeft}px`, top: '2rem', width: '256px' }}
>
<Link
href="/"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors hover:bg-blue-50 text-blue-600 hover:text-blue-700"
>
<ArrowLeft className="w-5 h-5" />
<span className="text-sm font-medium"></span>
</Link>
</div>
</div>
{/* 返回首页按钮 - 移动端固定定位 */}
<div className="lg:hidden">
<div className="fixed left-4 top-4 z-20">
<Link
href="/"
className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg shadow-lg border border-gray-200 text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-xs font-medium"></span>
</Link>
</div>
</div>
{/* 侧边栏目录 - 桌面端固定定位 */}
<div className="hidden lg:block">
<div
className="fixed w-64 bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-h-[calc(100vh-10rem)] overflow-y-auto z-10"
style={{ left: `${sidebarLeft}px`, top: '7rem' }}
>
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<nav className="space-y-2">
{sections.map((section) => (
<div key={section.id}>
<button
onClick={() => scrollToSection(section.id)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
activeSection === section.id
? 'bg-blue-50 text-blue-700 border border-blue-200'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
{section.icon}
<span className="text-sm font-medium">{section.title}</span>
<ChevronRight className="w-4 h-4 ml-auto" />
</button>
{/* 子目录 */}
{section.children && (
<div className="ml-8 mt-1 space-y-1">
{section.children.map((child) => (
<button
key={child.id}
onClick={() => scrollToSection(child.id)}
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-left transition-colors ${
activeSection === child.id
? 'bg-blue-100 text-blue-600 border border-blue-200'
: 'hover:bg-gray-50 text-gray-600'
}`}
>
{child.icon}
<span className="text-xs text-gray-700">{child.title}</span>
</button>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
{/* 移动端目录 - 粘性定位 */}
<div className="lg:hidden mb-6 mt-16">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto">
<h3 className="text-base font-semibold text-gray-900 mb-3"></h3>
<nav className="space-y-1">
{sections.map((section) => (
<div key={section.id}>
<button
onClick={() => scrollToSection(section.id)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
activeSection === section.id
? 'bg-blue-50 text-blue-700 border border-blue-200'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
{section.icon}
<span className="text-xs font-medium">{section.title}</span>
<ChevronRight className="w-3 h-3 ml-auto" />
</button>
{/* 子目录 */}
{section.children && (
<div className="ml-6 mt-1 space-y-1">
{section.children.map((child) => (
<button
key={child.id}
onClick={() => scrollToSection(child.id)}
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-left transition-colors ${
activeSection === child.id
? 'bg-blue-100 text-blue-600 border border-blue-200'
: 'hover:bg-gray-50 text-gray-600'
}`}
>
{child.icon}
<span className="text-xs text-gray-700">{child.title}</span>
</button>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
{/* 主要内容 */}
<div className="lg:ml-72 lg:mr-4">
{/* 部署指南 */}
<Section id="deployment" title="部署指南" icon={<Server className="w-6 h-6 text-blue-600" />}>
<div className="space-y-8">
<div>
<p className="text-gray-700 mb-6">
</p>
</div>
{/* Docker 部署 */}
<div className="scroll-mt-20" id="docker-deployment">
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Container className="w-6 h-6 text-blue-600" />
Docker
</h3>
<div className="space-y-6">
<div>
<h4 className="text-lg font-semibold mb-3">使 Docker Compose</h4>
<CodeBlock code={`git clone https://github.com/MatrixSeven/file-transfer-go.git
cd file-transfer-go
docker-compose up -d`} />
</div>
<div>
<h4 className="text-lg font-semibold mb-3">使 Docker </h4>
<CodeBlock code={`docker run -d -p 8080:8080 --name file-transfer-go matrixseven/file-transfer-go:latest`} />
</div>
<InfoBox type="tip" title="部署提示">
<ul className="list-disc list-inside space-y-1">
<li>Docker Compose </li>
<li>访 <code className="bg-gray-100 px-2 py-1 rounded">http://localhost:8080</code></li>
<li> <code className="bg-gray-100 px-2 py-1 rounded">docker-compose.yml</code> </li>
</ul>
</InfoBox>
</div>
</div>
{/* 二进制部署 */}
<div className="scroll-mt-20" id="binary-deployment">
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Download className="w-6 h-6 text-green-600" />
</h3>
<div className="space-y-6">
<div>
<h4 className="text-lg font-semibold mb-3"></h4>
<p className="text-gray-700 mb-3">
<a
href="https://github.com/MatrixSeven/file-transfer-go/releases/"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
target="_blank"
rel="noopener noreferrer"
>
GitHub Releases <ExternalLink className="w-4 h-4" />
</a>
</p>
<div className="bg-gray-50 rounded-lg p-4">
<h5 className="font-semibold mb-2"></h5>
<ul className="list-disc list-inside space-y-1 text-gray-700">
<li>Linux (AMD64/ARM64)</li>
<li>Windows (AMD64)</li>
<li>macOS (AMD64/ARM64)</li>
</ul>
</div>
</div>
<div>
<h4 className="text-lg font-semibold mb-3"></h4>
<p className="text-gray-700 mb-3"></p>
<CodeBlock code={`# Linux/macOS
chmod +x file-transfer-server-linux-amd64
./file-transfer-server-linux-amd64
# Windows
file-transfer-server-windows-amd64.exe`} />
</div>
<InfoBox type="info" title="注意事项">
<ul className="list-disc list-inside space-y-1">
<li></li>
<li> 8080</li>
<li>使 systemd </li>
</ul>
</InfoBox>
</div>
</div>
{/* 自行构建 */}
<div className="scroll-mt-20" id="build-deployment">
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Code className="w-6 h-6 text-purple-600" />
</h3>
<div className="space-y-6">
<div>
<h4 className="text-lg font-semibold mb-3"></h4>
<div className="bg-gray-50 rounded-lg p-4">
<ul className="list-disc list-inside space-y-1 text-gray-700">
<li>Go 1.21 </li>
<li>Node.js 18 </li>
<li>Git</li>
</ul>
</div>
</div>
<div>
<h4 className="text-lg font-semibold mb-3"></h4>
<CodeBlock code={`git clone https://github.com/MatrixSeven/file-transfer-go.git
cd file-transfer-go
./build-fullstack.sh
./dist/file-transfer-go`} />
</div>
<InfoBox type="warning" title="构建注意事项">
<ul className="list-disc list-inside space-y-1">
<li> Go npm </li>
<li></li>
<li></li>
</ul>
</InfoBox>
</div>
</div>
</div>
</Section>
{/* 桌面共享权限 */}
<Section id="desktop-share" title="桌面共享权限问题" icon={<Monitor className="w-6 h-6 text-blue-600" />}>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Smartphone className="w-5 h-5" />
</h3>
<InfoBox type="error" title="移动端限制">
<p>WebRTC </p>
<p className="mt-2 font-semibold">使</p>
</InfoBox>
</div>
<div>
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
<Monitor className="w-5 h-5" />
PC
</h3>
<InfoBox type="warning" title="HTTPS 要求">
<p>/访 TLS <code className="bg-gray-100 px-2 py-1 rounded">https</code> 访</p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li><code className="bg-gray-100 px-2 py-1 rounded">localhost</code> </li>
<li> nginx HTTPS</li>
<li> IP </li>
</ul>
</InfoBox>
<InfoBox type="tip" title="临时解决方案">
<p> IP+ </p>
<ol className="list-decimal list-inside space-y-1 mt-2">
<li></li>
<li> WebRTC </li>
<li> <code className="bg-gray-100 px-2 py-1 rounded">Anonymize local IPs exposed by WebRTC</code></li>
<li> <code className="bg-gray-100 px-2 py-1 rounded">Enabled</code> </li>
</ol>
</InfoBox>
</div>
</div>
</Section>
{/* 端口配置 */}
<Section id="port-config" title="端口配置" icon={<Settings className="w-6 h-6 text-blue-600" />}>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<p className="text-gray-700 mb-3"> Linux 18080 </p>
<CodeBlock code="./file-transfer-server-linux-amd64 -port 18080" />
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Docker </h3>
<p className="text-gray-700 mb-3">使 Docker </p>
<CodeBlock code="docker run -d -p 18080:8080 matrixseven/file-transfer-go:latest" />
</div>
<InfoBox type="info" title="端口选择建议">
<ul className="list-disc list-inside space-y-1">
<li>使1-1024</li>
<li></li>
<li></li>
<li>使 8080, 3000, 8000 </li>
</ul>
</InfoBox>
</div>
</Section>
{/* 安全内网部署 */}
<Section id="security" title="安全内网部署" icon={<Shield className="w-6 h-6 text-blue-600" />}>
<div className="space-y-6">
<InfoBox type="warning" title="实验性功能">
<p></p>
</InfoBox>
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="space-y-4">
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="font-semibold mb-2">1. DNS </h4>
<p className="text-gray-700">使 IP 访</p>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="font-semibold mb-2">2. STUN/TURN </h4>
<p className="text-gray-700"> STUN/TURN NAT 穿</p>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<h4 className="font-semibold mb-2">3. </h4>
<p className="text-gray-700"> STUN/TURN </p>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">STUN/TURN </h3>
<div className="bg-gray-50 rounded-lg p-4">
<ul className="list-disc list-inside space-y-1 text-gray-700">
<li><strong>Coturn</strong> TURN/STUN </li>
<li><strong>Janus</strong>WebRTC STUN/TURN </li>
<li><strong></strong> Docker </li>
</ul>
</div>
</div>
<InfoBox type="tip" title="配置提示">
<p> ICE </p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li>STUN <code className="bg-gray-100 px-2 py-1 rounded">stun:your-server.local:3478</code></li>
<li>TURN <code className="bg-gray-100 px-2 py-1 rounded">turn:your-server.local:3478</code></li>
<li>TURN </li>
</ul>
</InfoBox>
</div>
</Section>
{/* 数据传输说明 */}
<Section id="data-transfer" title="数据传输机制" icon={<Wifi className="w-6 h-6 text-blue-600" />}>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border rounded-lg p-4">
<h4 className="font-semibold mb-2 text-green-600"> </h4>
<p className="text-sm text-gray-600"> WebRTC </p>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold mb-2 text-blue-600"> </h4>
<p className="text-sm text-gray-600"> TURN </p>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">1</div>
<div>
<h4 className="font-semibold"></h4>
<p className="text-gray-600"> WebSocket </p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">2</div>
<div>
<h4 className="font-semibold">NAT 穿</h4>
<p className="text-gray-600">使 STUN </p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">3</div>
<div>
<h4 className="font-semibold"></h4>
<p className="text-gray-600"> P2P TURN </p>
</div>
</div>
</div>
</div>
<InfoBox type="info" title="隐私保护">
<ul className="list-disc list-inside space-y-1">
<li></li>
<li></li>
<li></li>
<li>使使 TURN </li>
</ul>
</InfoBox>
</div>
</Section>
{/* 交流反馈 */}
<Section id="contact" title="交流反馈" icon={<Users className="w-6 h-6 text-blue-600" />}>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="flex flex-col md:flex-row gap-6 items-start">
<div className="flex-1">
<p className="text-gray-700 mb-4">
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700">
<li></li>
<li>使</li>
<li></li>
<li></li>
</ul>
</div>
<div className="flex-shrink-0">
<div className="bg-gray-50 rounded-lg p-4 text-center">
<img
src="https://cdn-img.luxika.cc//i/2025/09/04/68b8f0d135edc.png"
alt="交流反馈群二维码"
className="w-32 h-32 mx-auto rounded-lg"
/>
<p className="text-sm text-gray-600 mt-2"></p>
</div>
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border rounded-lg p-4">
<h4 className="font-semibold mb-2 flex items-center gap-2">
<ExternalLink className="w-5 h-5 text-blue-600" />
GitHub Issues
</h4>
<p className="text-sm text-gray-600 mb-2"> Bug </p>
<a
href="https://github.com/MatrixSeven/file-transfer-go/issues"
className="text-blue-600 hover:underline text-sm"
target="_blank"
rel="noopener noreferrer"
>
Issues
</a>
</div>
<div className="border rounded-lg p-4">
<h4 className="font-semibold mb-2 flex items-center gap-2">
<Book className="w-5 h-5 text-green-600" />
</h4>
<p className="text-sm text-gray-600 mb-2"></p>
<a
href="https://github.com/MatrixSeven/file-transfer-go"
className="text-blue-600 hover:underline text-sm"
target="_blank"
rel="noopener noreferrer"
>
</a>
</div>
</div>
</div>
<InfoBox type="tip" title="反馈建议">
<p></p>
<ul className="list-disc list-inside space-y-1 mt-2">
<li></li>
<li>Docker//</li>
<li></li>
<li>//NAT类型</li>
<li></li>
</ul>
</InfoBox>
</div>
</Section>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { Metadata } from 'next'
export const metadata: Metadata = {
title: '使用帮助 - 文件传输工具',
description: '详细的部署指南和使用说明,帮助您快速上手文件传输工具',
keywords: ['文件传输', '帮助文档', '部署指南', 'WebRTC', '使用说明'],
}
export default function HelpLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}

View File

@@ -0,0 +1,5 @@
import HelpPage from '@/app/help/HelpPage'
export default function Help() {
return <HelpPage />
}

View File

@@ -25,6 +25,9 @@ export default function RootLayout({
}>) {
return (
<html lang="zh-CN">
<head>
<script defer src="https://track.biu.52python.cn/script.js" data-website-id="28f20618-8d31-421d-8ee2-16fcde0e299a"></script>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { useReadConnectState } from '@/hooks/connection/state/useWebConnectStateManager';
import { Role } from '@/hooks/connection/types';
import { useWebRTCStore } from '@/hooks/index';
import { cn } from '@/lib/utils';
interface ConnectionStatusProps {
// 房间信息 - 只需要这个基本信息
@@ -14,27 +15,23 @@ interface ConnectionStatusProps {
}
// 连接状态枚举
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: '建立房间连接中...',
};
const getConnectionStatus = (
currentRoom: { code: string; role: Role } | null,
connection: {
isWebSocketConnected: boolean;
isPeerConnected: boolean;
isConnecting: boolean;
error: string | null;
currentConnectType: 'webrtc' | 'websocket';
isJoinedRoom: boolean;
}
) => {
const isWebSocketConnected = connection.isWebSocketConnected;
const isPeerConnected = connection.isPeerConnected;
const isConnecting = connection.isConnecting;
const error = connection.error;
const currentConnectType = connection.currentConnectType;
const isJoinedRoom = connection.isJoinedRoom;
if (!currentRoom) {
return {
@@ -44,17 +41,74 @@ const getConnectionStatus = (connection: any, currentRoom: any) => {
};
}
if (error) {
return {
type: 'error' as const,
message: '连接失败',
detail: error,
};
}
if (currentConnectType === 'websocket') {
if (isWebSocketConnected && isJoinedRoom) {
return {
type: 'connected' as const,
message: 'P2P链接失败,WS降级中',
detail: 'WebSocket传输模式已就绪',
};
}
return {
type: 'room-ready' as const,
message: '房间已创建',
detail: '等待对方加入并建立WS连接...',
};
}
if (isConnecting) {
return {
type: 'connecting' as const,
message: '正在连接',
detail: '建立房间连接中...',
};
}
// 如果有房间信息但WebSocket未连接且不是正在连接状态
// 可能是状态更新的时序问题,显示连接中状态
if (!isWebSocketConnected && !isConnecting) {
if (isPeerConnected) {
return {
type: 'connected' as const,
message: 'P2P连接成功',
detail: '可以开始传输',
};
}
if (!isWebSocketConnected) {
return {
type: 'connecting' as const,
message: '连接中',
detail: '正在建立WebSocket连接...',
};
}
if (!isJoinedRoom) {
return {
type: 'room-ready' as const,
message: '房间已创建',
detail: '等待对方加入并建立P2P连接...',
};
}
if (isJoinedRoom) {
return {
type: 'room-ready' as const,
message: '对方已加入房间',
detail: '正在建立P2P连接...',
};
}
if (isWebSocketConnected && !isPeerConnected) {
if (isJoinedRoom && !isPeerConnected) {
return {
type: 'room-ready' as const,
message: '房间已创建',
@@ -62,14 +116,8 @@ const getConnectionStatus = (connection: any, currentRoom: any) => {
};
}
if (isWebSocketConnected && isPeerConnected) {
return {
type: 'connected' as const,
message: 'P2P连接成功',
detail: '可以开始传输',
};
}
console.log('Unknown connection state:', connection);
return {
type: 'unknown' as const,
message: '状态未知',
@@ -85,6 +133,8 @@ const getStatusColor = (type: string) => {
case 'connecting':
case 'room-ready':
return 'text-yellow-600';
case 'websocket-ready':
return 'text-orange-600';
case 'error':
return 'text-red-600';
case 'disconnected':
@@ -106,6 +156,8 @@ const StatusIcon = ({ type, className = 'w-3 h-3' }: { type: string; className?:
return (
<div className={cn(iconClass, 'bg-yellow-500 rounded-full animate-pulse')} />
);
case 'websocket-ready':
return <div className={cn(iconClass, 'bg-orange-500 rounded-full')} />;
case 'error':
return <div className={cn(iconClass, 'bg-red-500 rounded-full')} />;
case 'disconnected':
@@ -116,72 +168,86 @@ const StatusIcon = ({ type, className = 'w-3 h-3' }: { type: string; className?:
};
// 获取连接状态文字描述
const getConnectionStatusText = (connection: any) => {
const getConnectionStatusText = (connection: { isWebSocketConnected?: boolean; isPeerConnected?: boolean; isConnecting?: boolean; error?: string | null; currentConnectType?: 'webrtc' | 'websocket' }) => {
const isWebSocketConnected = connection?.isWebSocketConnected || false;
const isPeerConnected = connection?.isPeerConnected || false;
const isConnecting = connection?.isConnecting || false;
const error = connection?.error || null;
const currentConnectType = connection?.currentConnectType || 'webrtc';
const wsStatus = isWebSocketConnected ? 'WS已连接' : 'WS未连接';
const rtcStatus = isPeerConnected ? 'RTC已连接' :
const rtcStatus = isPeerConnected ? 'RTC已连接' :
isWebSocketConnected ? 'RTC等待连接' : 'RTC未连接';
if (error) {
return `${wsStatus} ${rtcStatus} - 连接失败`;
}
if (isConnecting) {
return `${wsStatus} ${rtcStatus} - 连接中`;
}
if (isPeerConnected) {
return `${wsStatus} ${rtcStatus} - P2P连接成功`;
}
// 如果WebSocket已连接但P2P未连接且当前连接类型是websocket
if (isWebSocketConnected && !isPeerConnected && currentConnectType === 'websocket') {
return `${wsStatus} ${rtcStatus} - P2P链接失败,将使用WS进行传输`;
}
return `${wsStatus} ${rtcStatus}`;
};
export function ConnectionStatus(props: ConnectionStatusProps) {
const { currentRoom, className, compact = false, inline = false } = props;
// 使用全局WebRTC状态
const webrtcState = useWebRTCStore();
// 获取连接状态
const { getConnectState } = useReadConnectState();
const connectionState = getConnectState();
// 创建connection对象以兼容现有代码
const connection = {
isWebSocketConnected: webrtcState.isWebSocketConnected,
isPeerConnected: webrtcState.isPeerConnected,
isConnecting: webrtcState.isConnecting,
error: webrtcState.error,
currentConnectType: webrtcState.currentConnectType,
isJoinedRoom: connectionState?.isJoinedRoom || false,
};
const isConnected = webrtcState.isWebSocketConnected && webrtcState.isPeerConnected;
// 如果是内联模式,只返回状态文字
if (inline) {
return <span className={cn('text-sm text-slate-600', className)}>{getConnectionStatusText(connection)}</span>;
}
const status = getConnectionStatus(connection, currentRoom);
const status = getConnectionStatus(currentRoom ?? null, connection);
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"
<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"
<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>
@@ -205,9 +271,9 @@ export function ConnectionStatus(props: ConnectionStatusProps) {
<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"
<StatusIcon
type={connection.isWebSocketConnected ? 'connected' : 'disconnected'}
className="w-2.5 h-2.5"
/>
<span className={cn(
connection.isWebSocketConnected ? 'text-green-600' : 'text-slate-500'
@@ -215,14 +281,14 @@ export function ConnectionStatus(props: ConnectionStatusProps) {
{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"
<StatusIcon
type={connection.isPeerConnected ? 'connected' : 'disconnected'}
className="w-2.5 h-2.5"
/>
<span className={cn(
connection.isPeerConnected ? 'text-green-600' : 'text-slate-500'
@@ -232,32 +298,8 @@ export function ConnectionStatus(props: ConnectionStatusProps) {
</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

@@ -1,30 +1,74 @@
"use client";
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import Link from 'next/link';
import { useURLHandler } from '@/hooks/ui';
import { Button } from '@/components/ui/button';
import { Share, Monitor } from 'lucide-react';
import WebRTCDesktopReceiver from '@/components/webrtc/WebRTCDesktopReceiver';
import WebRTCDesktopSender from '@/components/webrtc/WebRTCDesktopSender';
import { useWebRTCStore } from '@/hooks/index';
import { Share, Monitor, AlertTriangle, ExternalLink } from 'lucide-react';
import WebRTCDesktopReceiver from '@/components/webrtc/WebRTCDesktopReceiver';
import WebRTCDesktopSender from '@/components/webrtc/WebRTCDesktopSender';
interface DesktopShareProps {
// 保留向后兼容性的props已废弃但保留接口
onStartSharing?: () => Promise<string>;
onStopSharing?: () => Promise<void>;
onJoinSharing?: (code: string) => Promise<void>;
}
export default function DesktopShare({
onStartSharing,
onStopSharing,
onJoinSharing
// 检测是否支持屏幕分享
function useScreenShareSupport() {
const [isSupported, setIsSupported] = useState(true);
const [reason, setReason] = useState<string>('');
useEffect(() => {
const checkScreenShareSupport = async () => {
try {
// 首先检查是否存在 getDisplayMedia API
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
setIsSupported(false);
setReason('api-not-supported');
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
setIsSupported(false);
setReason('mobile');
return;
}
// 检查安全上下文 - getDisplayMedia 需要安全上下文HTTPS 或 localhost
if (!window.isSecureContext) {
const isLocalhost = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '[::1]';
if (!isLocalhost) {
setIsSupported(false);
setReason('insecure-context');
return;
}
return
}
setIsSupported(true);
setReason('');
}
}
catch (error) {
console.error('Error checking screen share support:', error);
setIsSupported(false);
setReason('unknown-error');
}
};
checkScreenShareSupport();
}, []);
return { isSupported, reason };
}
export default function DesktopShare({
onJoinSharing
}: DesktopShareProps) {
const [mode, setMode] = useState<'share' | 'view'>('share');
// 使用全局WebRTC状态
const webrtcState = useWebRTCStore();
const { isSupported, reason } = useScreenShareSupport();
// 使用统一的URL处理器带模式转换
const { updateMode, getCurrentRoomCode } = useURLHandler({
@@ -44,14 +88,78 @@ export default function DesktopShare({
return code;
}, [getCurrentRoomCode]);
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
const handleConnectionChange = useCallback((connection: any) => {
// 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它
// 连接状态变化处理 - 为了兼容现有的子组件接口,保留它
const handleConnectionChange = useCallback((connection: { isConnected: boolean; isWebSocketConnected: boolean }) => {
console.log('桌面共享连接状态变化:', connection);
}, []);
// 获取提示信息
const getWarningInfo = () => {
switch (reason) {
case 'mobile':
return {
title: '移动端不支持屏幕分享',
message: '移动端浏览器不支持获取桌面视频流,请使用桌面设备进行屏幕共享。'
};
case 'api-not-supported':
return {
title: '浏览器不支持屏幕分享',
message: '当前浏览器不支持 getDisplayMedia API请使用支持屏幕分享的现代浏览器如 Chrome、Firefox、Edge 等)。'
};
case 'insecure-context':
return {
title: '需要安全上下文',
message: '屏幕分享功能需要在安全上下文中使用HTTPS协议或localhost当前环境不支持。'
};
case 'detection-failed':
return {
title: '检测屏幕分享支持失败',
message: '无法检测屏幕分享支持情况,这可能是由于浏览器限制或权限问题。'
};
case 'unknown-error':
return {
title: '未知错误',
message: '检测屏幕分享支持时发生未知错误,请尝试刷新页面或使用其他浏览器。'
};
case 'ip-http':
return {
title: '当前环境不支持屏幕分享',
message: '使用IP地址访问时浏览器要求HTTPS协议才能进行屏幕分享。请配置HTTPS或使用localhost访问。'
};
case 'non-https':
return {
title: '需要HTTPS协议',
message: '屏幕分享功能需要在HTTPS环境下使用请使用HTTPS协议访问或在本地环境测试。'
};
default:
return null;
}
};
const warningInfo = getWarningInfo();
return (
<div className="space-y-4 sm:space-y-6">
{/* 环境不支持提示 */}
{!isSupported && warningInfo && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold text-amber-900 mb-1">{warningInfo.title}</h3>
<p className="text-amber-800 text-sm mb-3">{warningInfo.message}</p>
<Link
href="/help#desktop-share"
className="inline-flex items-center gap-2 text-sm text-amber-700 hover:text-amber-900 underline"
>
<ExternalLink className="w-4 h-4" />
</Link>
</div>
</div>
</div>
)}
{/* 模式选择器 */}
<div className="flex justify-center mb-6">
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
@@ -59,6 +167,7 @@ export default function DesktopShare({
variant={mode === 'share' ? 'default' : 'ghost'}
onClick={() => updateMode('share')}
className="px-6 py-2 rounded-lg"
disabled={!isSupported && mode === 'share'}
>
<Share className="w-4 h-4 mr-2" />
@@ -79,7 +188,7 @@ export default function DesktopShare({
{mode === 'share' ? (
<WebRTCDesktopSender onConnectionChange={handleConnectionChange} />
) : (
<WebRTCDesktopReceiver
<WebRTCDesktopReceiver
initialCode={getInitialCode()}
onConnectionChange={handleConnectionChange}
/>

View File

@@ -1,8 +1,8 @@
"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';
import { Maximize, Minimize, Monitor, Play, Settings, Volume2, VolumeX, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface DesktopViewerProps {
stream: MediaStream | null;
@@ -11,16 +11,16 @@ interface DesktopViewerProps {
onDisconnect: () => void;
}
export default function DesktopViewer({
stream,
isConnected,
connectionCode,
onDisconnect
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 [isMuted, setIsMuted] = useState(true);
const [showControls, setShowControls] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [needsUserInteraction, setNeedsUserInteraction] = useState(false);
@@ -37,17 +37,23 @@ export default function DesktopViewer({
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);
console.log('[DesktopViewer] 轨道详情:', track.kind, track.id, '启用:', track.enabled, '状态:', track.readyState);
// 确保轨道已启用
if (!track.enabled) {
console.log('[DesktopViewer] 🔓 启用轨道:', track.id);
track.enabled = true;
}
});
videoRef.current.srcObject = stream;
console.log('[DesktopViewer] ✅ 视频元素已设置流');
videoRef.current.muted = true; // 确保默认静音
console.log('[DesktopViewer] ✅ 视频元素已设置流并静音');
// 重置状态
hasAttemptedAutoplayRef.current = false;
setNeedsUserInteraction(false);
setIsPlaying(false);
// 添加事件监听器来调试视频加载
const video = videoRef.current;
const handleLoadStart = () => console.log('[DesktopViewer] 📹 视频开始加载');
@@ -82,15 +88,39 @@ export default function DesktopViewer({
console.log('[DesktopViewer] 📹 视频暂停');
setIsPlaying(false);
};
const handleError = (e: Event) => console.error('[DesktopViewer] 📹 视频播放错误:', e);
const handleError = (e: Event) => {
console.error('[DesktopViewer] 📹 视频播放错误:', e);
// 尝试重新加载流
console.log('[DesktopViewer] 🔄 尝试重新加载视频流');
setTimeout(() => {
if (videoRef.current && stream) {
videoRef.current.srcObject = null;
videoRef.current.srcObject = stream;
if (!hasAttemptedAutoplayRef.current) {
hasAttemptedAutoplayRef.current = true;
videoRef.current.play()
.then(() => {
console.log('[DesktopViewer] ✅ 重新加载后视频播放成功');
setIsPlaying(true);
setNeedsUserInteraction(false);
})
.catch(err => {
console.log('[DesktopViewer] 📹 重新加载后自动播放仍被阻止:', err.message);
setIsPlaying(false);
setNeedsUserInteraction(true);
});
}
}
}, 1000);
};
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);
@@ -139,32 +169,58 @@ export default function DesktopViewer({
const handleFullscreenChange = () => {
const isCurrentlyFullscreen = !!document.fullscreenElement;
setIsFullscreen(isCurrentlyFullscreen);
if (isCurrentlyFullscreen) {
// 全屏时自动隐藏控制栏,鼠标移动时显示
setShowControls(false);
} else {
// 退出全屏时显示控制栏
setShowControls(true);
// 延迟检查视频状态,确保全屏切换完成
setTimeout(() => {
if (videoRef.current && stream) {
console.log('[DesktopViewer] 🔄 退出全屏,检查视频状态');
// 确保视频流正确设置
const currentSrcObject = videoRef.current.srcObject;
if (!currentSrcObject || currentSrcObject !== stream) {
videoRef.current.srcObject = stream;
}
// 检查视频是否暂停
if (videoRef.current.paused) {
console.log('[DesktopViewer] ⏸️ 退出全屏后视频已暂停,显示播放按钮');
setIsPlaying(false);
setNeedsUserInteraction(true);
hasAttemptedAutoplayRef.current = true; // 标记已尝试过自动播放
} else {
console.log('[DesktopViewer] ▶️ 退出全屏后视频仍在播放');
setIsPlaying(true);
setNeedsUserInteraction(false);
}
}
}, 200); // 延迟200ms确保全屏切换完成
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
}, [stream]);
// 鼠标移动处理(全屏时)
const handleMouseMove = useCallback(() => {
if (isFullscreen) {
setShowControls(true);
// 清除之前的定时器
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
}
// 3秒后自动隐藏控制栏
hideControlsTimeoutRef.current = setTimeout(() => {
setShowControls(false);
@@ -199,7 +255,7 @@ export default function DesktopViewer({
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
@@ -207,13 +263,43 @@ export default function DesktopViewer({
// 切换全屏
const toggleFullscreen = useCallback(async () => {
if (!containerRef.current) return;
if (!videoRef.current) return;
try {
if (isFullscreen) {
await document.exitFullscreen();
// 退出全屏
if (document.fullscreenElement) {
await document.exitFullscreen();
}
// 退出iOS全屏模式
if ((document as any).webkitExitFullscreen) {
await (document as any).webkitExitFullscreen();
}
// 退出视频全屏模式
if ((videoRef.current as any).webkitExitFullscreen) {
await (videoRef.current as any).webkitExitFullscreen();
}
// 退出Android全屏模式
if ((videoRef.current as any).exitFullscreen) {
await (videoRef.current as any).exitFullscreen();
}
} else {
await containerRef.current.requestFullscreen();
// 进入标准全屏
if (videoRef.current.requestFullscreen) {
await videoRef.current.requestFullscreen();
}
// 进入iOS全屏模式
else if ((videoRef.current as any).webkitRequestFullscreen) {
await (videoRef.current as any).webkitRequestFullscreen();
}
// 进入iOS视频全屏模式
else if ((videoRef.current as any).webkitEnterFullscreen) {
await (videoRef.current as any).webkitEnterFullscreen();
}
// 进入Android全屏模式
else if ((videoRef.current as any).requestFullscreen) {
await (videoRef.current as any).requestFullscreen();
}
}
} catch (error) {
console.error('[DesktopViewer] 全屏切换失败:', error);
@@ -223,9 +309,22 @@ export default function DesktopViewer({
// 退出全屏
const exitFullscreen = useCallback(async () => {
try {
// 退出标准全屏
if (document.fullscreenElement) {
await document.exitFullscreen();
}
// 退出iOS全屏模式
if ((document as any).webkitExitFullscreen) {
await (document as any).webkitExitFullscreen();
}
// 退出视频全屏模式
if (videoRef.current && (videoRef.current as any).webkitExitFullscreen) {
await (videoRef.current as any).webkitExitFullscreen();
}
// 退出Android全屏模式
if (videoRef.current && (videoRef.current as any).exitFullscreen) {
await (videoRef.current as any).exitFullscreen();
}
} catch (error) {
console.error('[DesktopViewer] 退出全屏失败:', error);
}
@@ -295,21 +394,21 @@ export default function DesktopViewer({
playsInline
muted={isMuted}
className={`w-full h-full object-contain ${isFullscreen ? 'cursor-none' : ''}`}
style={{
style={{
aspectRatio: isFullscreen ? 'unset' : '16/9',
minHeight: isFullscreen ? '100vh' : '400px'
}}
/>
{/* 需要用户交互的播放覆盖层 - 只在自动播放尝试失败后显示 */}
{hasAttemptedAutoplayRef.current && needsUserInteraction && !isPlaying && (
{/* 需要用户交互的播放覆盖层 - 在视频暂停时显示 */}
{((needsUserInteraction && !isPlaying) || (isConnected && !isPlaying && !needsUserInteraction && videoRef.current?.paused)) && (
<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>
<p className="text-sm opacity-75"></p>
</div>
</div>
)}
@@ -327,27 +426,26 @@ export default function DesktopViewer({
{/* 控制栏 */}
<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'
}`}
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 flex-col sm:flex-row items-center justify-between gap-4">
{/* 左侧信息 */}
<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>
<span className="text-xs sm:text-sm">{isPlaying ? '桌面共享中' : needsUserInteraction ? '等待播放' : isConnected ? '已暂停' : '连接中'}</span>
</div>
{videoStats.resolution !== '0x0' && (
<>
<div className="w-px h-4 bg-white/30"></div>
<span>{videoStats.resolution}</span>
<div className="w-px h-4 bg-white/30 hidden sm:block"></div>
<span className="text-xs sm:text-sm hidden sm:block">{videoStats.resolution}</span>
</>
)}
{connectionCode && (
<>
<div className="w-px h-4 bg-white/30"></div>
<span className="font-mono">{connectionCode}</span>
<div className="w-px h-4 bg-white/30 hidden sm:block"></div>
<span className="font-mono text-xs sm:text-sm hidden sm:block">{connectionCode}</span>
</>
)}
</div>
@@ -372,7 +470,7 @@ export default function DesktopViewer({
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-white/20"
className="text-white hover:bg-white/20 hidden sm:flex"
>
<Settings className="w-4 h-4" />
</Button>
@@ -413,6 +511,19 @@ export default function DesktopViewer({
)}
</div>
{/* 移动端浮动全屏按钮 - 在控制栏隐藏时显示 */}
{!isFullscreen && (
<Button
variant="ghost"
size="lg"
onClick={toggleFullscreen}
className="fixed bottom-20 right-4 z-40 md:hidden bg-black/60 text-white hover:bg-black/80 rounded-full p-3 shadow-lg"
title="全屏"
>
<Maximize className="w-5 h-5" />
</Button>
)}
{/* 加载状态 */}
{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">
@@ -424,9 +535,8 @@ export default function DesktopViewer({
{/* 网络状态指示器 */}
<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>
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-yellow-500 animate-pulse'
}`}></div>
<span>{isConnected ? '已连接' : '连接中'}</span>
</div>
</div>

View File

@@ -0,0 +1,64 @@
"use client";
import React from 'react';
import { Github, HelpCircle, MessageCircle, Bug } from 'lucide-react';
import Link from 'next/link';
export default function Footer() {
return (
<footer className="mt-auto py-4 shrink-0">
<div className="container mx-auto px-4">
{/* 分割线 */}
<div className="w-full h-px bg-gradient-to-r from-transparent via-slate-200 to-transparent mb-4"></div>
{/* 链接区域 */}
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4 text-sm">
<Link
href="/help"
className="text-slate-500 hover:text-blue-500 transition-colors duration-200 flex items-center gap-1"
>
<HelpCircle className="w-3.5 h-3.5" />
</Link>
<a
href="https://github.com/MatrixSeven/file-transfer-go"
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-slate-700 transition-colors duration-200 flex items-center gap-1"
>
<Github className="w-3.5 h-3.5" />
</a>
<a
href="https://x.com/_MatrixSeven"
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-blue-400 transition-colors duration-200 flex items-center gap-1"
>
<MessageCircle className="w-3.5 h-3.5" />
X
</a>
<a
href="https://github.com/MatrixSeven/file-transfer-go/issues"
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-orange-500 transition-colors duration-200 flex items-center gap-1"
>
<Bug className="w-3.5 h-3.5" />
Issue
</a>
</div>
{/* 版权信息 */}
<div className="text-center mt-3">
<p className="text-xs text-slate-400">
WebRTC
</p>
</div>
</div>
</footer>
);
}

View File

@@ -1,7 +1,8 @@
"use client";
import React from 'react';
import { Github } from 'lucide-react';
import { Github, HelpCircle } from 'lucide-react';
import Link from 'next/link';
export default function Hero() {
return (
@@ -10,12 +11,10 @@ export default function Hero() {
</h1>
<p className="text-sm sm:text-base text-slate-600 max-w-xl mx-auto leading-relaxed px-4 mb-3">
<br />
<span className="text-xs sm:text-sm text-slate-500">WebRTC的端到端服务 - </span>
</p>
{/* GitHub开源链接 */}
{/* GitHub开源链接和帮助 */}
<div className="flex items-center justify-center gap-2 mb-4">
<a
href="https://github.com/MatrixSeven/file-transfer-go"
@@ -24,21 +23,20 @@ export default function Hero() {
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs sm:text-sm text-slate-600 hover:text-slate-800 bg-slate-100 hover:bg-slate-200 rounded-full transition-colors duration-200 border border-slate-200 hover:border-slate-300"
>
<Github className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="font-medium"></span>
<span className="font-medium"></span>
</a>
<span className="text-xs text-slate-400">|</span>
<a
href="https://github.com/MatrixSeven/file-transfer-go"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-slate-500 hover:text-slate-700 hover:underline transition-colors duration-200"
<Link
href="/help"
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs sm:text-sm text-blue-600 hover:text-blue-800 bg-blue-50 hover:bg-blue-100 rounded-full transition-colors duration-200 border border-blue-200 hover:border-blue-300"
>
https://github.com/MatrixSeven/file-transfer-go
</a>
<HelpCircle className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="font-medium">使</span>
</Link>
</div>
{/* 分割线 */}
<div className="w-64 sm:w-80 md:w-96 lg:w-[32rem] xl:w-[40rem] h-0.5 bg-gradient-to-r from-blue-400 via-purple-400 to-indigo-400 mx-auto mt-2 mb-2 opacity-60"></div>
<div className="w-full max-w-2xl h-0.5 bg-gradient-to-r from-blue-400 via-purple-400 to-indigo-400 mx-auto mt-2 mb-2 opacity-60"></div>
</div>
);
}

View File

@@ -24,7 +24,7 @@ export default function WeChatGroup() {
{/* 微信群二维码 - 请将此区域替换为实际的二维码图片 */}
<div className="relative">
<img
src="https://cdn-img.luxika.cc//i/2025/08/25/68abd75c363a6.png"
src="https://cdn-img.luxika.cc//i/2025/09/04/68b8f0d135edc.png"
alt="微信群二维码"
className="w-64 h-64 rounded-xl"
/>

View File

@@ -1,185 +0,0 @@
import React from 'react';
import { AlertCircle, Wifi, WifiOff, Loader2, RotateCcw } from 'lucide-react';
import { WebRTCConnection } from '@/hooks/connection/useSharedWebRTCManager';
interface Props {
webrtc: WebRTCConnection;
className?: string;
}
/**
* WebRTC连接状态显示组件
* 显示详细的连接状态、错误信息和重试按钮
*/
export function WebRTCConnectionStatus({ webrtc, className = '' }: Props) {
const {
isConnected,
isConnecting,
isWebSocketConnected,
isPeerConnected,
error,
canRetry,
retry
} = webrtc;
// 状态图标
const getStatusIcon = () => {
if (isConnecting) {
return <Loader2 className="h-4 w-4 animate-spin text-blue-500" />;
}
if (error) {
// 区分信息提示和错误
if (error.includes('对方已离开房间') || error.includes('已离开房间')) {
return <WifiOff className="h-4 w-4 text-yellow-500" />;
}
return <AlertCircle className="h-4 w-4 text-red-500" />;
}
if (isPeerConnected) {
return <Wifi className="h-4 w-4 text-green-500" />;
}
if (isWebSocketConnected) {
return <Wifi className="h-4 w-4 text-yellow-500" />;
}
return <WifiOff className="h-4 w-4 text-gray-400" />;
};
// 状态文本
const getStatusText = () => {
if (error) {
return error;
}
if (isConnecting) {
return '正在连接...';
}
if (isPeerConnected) {
return 'P2P连接已建立';
}
if (isWebSocketConnected) {
return '信令服务器已连接';
}
return '未连接';
};
// 状态颜色
const getStatusColor = () => {
if (error) {
// 区分信息提示和错误
if (error.includes('对方已离开房间') || error.includes('已离开房间')) {
return 'text-yellow-600';
}
return 'text-red-600';
}
if (isConnecting) return 'text-blue-600';
if (isPeerConnected) return 'text-green-600';
if (isWebSocketConnected) return 'text-yellow-600';
return 'text-gray-600';
};
const handleRetry = async () => {
try {
await retry();
} catch (error) {
console.error('重试连接失败:', error);
}
};
return (
<div className={`flex items-center justify-between p-3 bg-white border rounded-lg ${className}`}>
<div className="flex items-center gap-2">
{getStatusIcon()}
<span className={`text-sm font-medium ${getStatusColor()}`}>
{getStatusText()}
</span>
</div>
{/* 连接详细状态指示器 */}
<div className="flex items-center gap-1">
{/* WebSocket状态 */}
<div
className={`w-2 h-2 rounded-full ${
isWebSocketConnected ? 'bg-green-400' : 'bg-gray-300'
}`}
title={isWebSocketConnected ? 'WebSocket已连接' : 'WebSocket未连接'}
/>
{/* P2P状态 */}
<div
className={`w-2 h-2 rounded-full ${
isPeerConnected ? 'bg-green-400' : 'bg-gray-300'
}`}
title={isPeerConnected ? 'P2P连接已建立' : 'P2P连接未建立'}
/>
{/* 重试按钮 */}
{canRetry && (
<button
onClick={handleRetry}
disabled={isConnecting}
className="ml-2 p-1 text-gray-500 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
title="重试连接"
>
<RotateCcw className={`h-3 w-3 ${isConnecting ? 'animate-spin' : ''}`} />
</button>
)}
</div>
</div>
);
}
/**
* 简单的连接状态指示器(用于空间受限的地方)
*/
export function WebRTCStatusIndicator({ webrtc, className = '' }: Props) {
const { isPeerConnected, isConnecting, error } = webrtc;
if (error) {
// 区分信息提示和错误
if (error.includes('对方已离开房间') || error.includes('已离开房间')) {
return (
<div className={`flex items-center gap-1 ${className}`}>
<div className="w-2 h-2 bg-yellow-400 rounded-full" />
<span className="text-xs text-yellow-600"></span>
</div>
);
}
return (
<div className={`flex items-center gap-1 ${className}`}>
<div className="w-2 h-2 bg-red-400 rounded-full animate-pulse" />
<span className="text-xs text-red-600"></span>
</div>
);
}
if (isConnecting) {
return (
<div className={`flex items-center gap-1 ${className}`}>
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse" />
<span className="text-xs text-blue-600"></span>
</div>
);
}
if (isPeerConnected) {
return (
<div className={`flex items-center gap-1 ${className}`}>
<div className="w-2 h-2 bg-green-400 rounded-full" />
<span className="text-xs text-green-600"></span>
</div>
);
}
return (
<div className={`flex items-center gap-1 ${className}`}>
<div className="w-2 h-2 bg-gray-300 rounded-full" />
<span className="text-xs text-gray-600"></span>
</div>
);
}

View File

@@ -1,14 +1,14 @@
"use client";
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSharedWebRTCManager, useConnectionState, useRoomConnection } from '@/hooks/connection';
import { useFileTransferBusiness, useFileListSync, useFileStateManager } from '@/hooks/file-transfer';
import { useURLHandler } from '@/hooks/ui';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { Upload, Download } from 'lucide-react';
import { WebRTCFileUpload } from '@/components/webrtc/WebRTCFileUpload';
import { WebRTCFileReceive } from '@/components/webrtc/WebRTCFileReceive';
import { WebRTCFileUpload } from '@/components/webrtc/WebRTCFileUpload';
import { useConnectionState, useConnectManager, useRoomConnection } from '@/hooks/connection';
import { useFileListSync, useFileStateManager, useFileTransferBusiness } from '@/hooks/file-transfer';
import { useURLHandler } from '@/hooks/ui';
import { Download, Upload } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
interface FileInfo {
id: string;
@@ -34,8 +34,8 @@ export const WebRTCFileTransfer: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
// 创建共享连接
const connection = useSharedWebRTCManager();
const stableConnection = useMemo(() => connection, [connection.isConnected, connection.isConnecting, connection.isWebSocketConnected, connection.error]);
const connection = useConnectManager();
const stableConnection = useMemo(() => connection, [connection.getConnectState().isConnected, connection.getConnectState().isConnecting, connection.getConnectState().isWebSocketConnected, connection.getConnectState().error]);
// 使用共享连接创建业务层
const {
@@ -51,7 +51,8 @@ export const WebRTCFileTransfer: React.FC = () => {
onFileReceived,
onFileListReceived,
onFileRequested,
onFileProgress
onFileProgress,
clearSenderData
} = useFileTransferBusiness(stableConnection);
// 使用自定义 hooks
@@ -60,8 +61,8 @@ export const WebRTCFileTransfer: React.FC = () => {
mode,
pickupCode,
isConnected,
isPeerConnected: connection.isPeerConnected,
getChannelState: connection.getChannelState
isPeerConnected: connection.getConnectState().isPeerConnected,
getChannelState: () => connection.getConnectState().state
});
const {
@@ -75,15 +76,16 @@ export const WebRTCFileTransfer: React.FC = () => {
clearFiles,
resetFiles,
updateFileStatus,
updateFileProgress
updateFileProgress,
clearSenderData: clearFileStateData
} = useFileStateManager({
mode,
pickupCode,
syncFileListToReceiver,
isPeerConnected: connection.isPeerConnected
isPeerConnected: connection.getConnectState().isPeerConnected
});
const { joinRoom: originalJoinRoom, isJoiningRoom } = useRoomConnection({
const { joinRoom: originalJoinRoom } = useRoomConnection({
connect,
isConnecting,
isConnected
@@ -227,10 +229,6 @@ export const WebRTCFileTransfer: React.FC = () => {
console.log('连接已断开,忽略进度更新:', progressInfo.fileName);
return;
}
console.log('=== 文件进度更新 ===');
console.log('文件:', progressInfo.fileName, 'ID:', progressInfo.fileId, '进度:', progressInfo.progress);
// 更新当前传输文件信息
setCurrentTransferFile({
fileId: progressInfo.fileId,
@@ -264,28 +262,33 @@ export const WebRTCFileTransfer: React.FC = () => {
return;
}
console.log('当前选中的文件列表:', selectedFiles.map(f => f.name));
// 在发送方的selectedFiles中查找对应文件
const file = selectedFiles.find(f => f.name === fileName);
if (!file) {
console.error('找不到匹配的文件:', fileName);
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
showToast(`无法找到文件: ${fileName}`, "error");
return;
}
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
// 更新发送方文件状态为downloading
// 更新发送方文件状态为downloading - 统一使用updateFileStatus
updateFileStatus(fileId, 'downloading', 0);
// 发送文件
try {
sendFile(file, fileId);
// 移除不必要的Toast - 传输开始状态在UI中已经显示
} catch (sendError) {
console.error('发送文件失败:', sendError);
showToast(`发送文件失败: ${fileName}`, "error");
// 重置文件状态
// 重置文件状态 - 统一使用updateFileStatus
updateFileStatus(fileId, 'ready', 0);
}
} else {
@@ -340,86 +343,7 @@ export const WebRTCFileTransfer: React.FC = () => {
}
}, [error, mode, showToast, lastError]);
// 处理文件接收
useEffect(() => {
const cleanup = onFileReceived((fileData: { id: string; file: File }) => {
console.log('=== 接收到文件 ===');
console.log('文件:', fileData.file.name, 'ID:', fileData.id);
// 更新下载的文件
setDownloadedFiles(prev => new Map(prev.set(fileData.id, fileData.file)));
// 更新文件状态
setFileList(prev => prev.map(item =>
item.id === fileData.id
? { ...item, status: 'completed' as const, progress: 100 }
: item
));
// 移除不必要的Toast - 文件完成状态在UI中已经显示
});
return cleanup;
}, [onFileReceived]);
// 处理文件请求(发送方监听)
useEffect(() => {
const cleanup = onFileRequested((fileId: string, fileName: string) => {
console.log('=== 收到文件请求 ===');
console.log('文件:', fileName, 'ID:', fileId, '当前模式:', mode);
if (mode === 'send') {
// 检查连接状态
if (!isConnected || error) {
console.log('连接已断开,无法发送文件');
showToast('连接已断开,无法发送文件', "error");
return;
}
console.log('当前选中的文件列表:', selectedFiles.map(f => f.name));
// 在发送方的selectedFiles中查找对应文件
const file = selectedFiles.find(f => f.name === fileName);
if (!file) {
console.error('找不到匹配的文件:', fileName);
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
showToast(`无法找到文件: ${fileName}`, "error");
return;
}
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
// 更新发送方文件状态为downloading
setFileList(prev => prev.map(item =>
item.id === fileId || item.name === fileName
? { ...item, status: 'downloading' as const, progress: 0 }
: item
));
// 发送文件
try {
sendFile(file, fileId);
// 移除不必要的Toast - 传输开始状态在UI中已经显示
} catch (sendError) {
console.error('发送文件失败:', sendError);
showToast(`发送文件失败: ${fileName}`, "error");
// 重置文件状态
setFileList(prev => prev.map(item =>
item.id === fileId || item.name === fileName
? { ...item, status: 'ready' as const, progress: 0 }
: item
));
}
} else {
console.warn('接收模式下收到文件请求,忽略');
}
});
return cleanup;
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error]);
// 监听连接状态变化和清理传输状态
useEffect(() => {
@@ -483,24 +407,73 @@ export const WebRTCFileTransfer: React.FC = () => {
selectedFilesCount: selectedFiles.length,
fileListCount: fileList.length
});
}, [isConnected, connection.isPeerConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, fileList.length]);
}, [isConnected, connection.getConnectState().isPeerConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, fileList.length]);
// 监听P2P连接建立时的状态变化
useEffect(() => {
if (connection.isPeerConnected && mode === 'send' && fileList.length > 0) {
console.log('P2P连接已建立数据通道首次打开初始化文件列表');
const connectState = connection.getConnectState();
const isPeerConnected = connectState.isPeerConnected;
const isDataChannelConnected = connectState.isDataChannelConnected;
const isChannelOpen = connectState.state === 'open';
const isConnected = connectState.isConnected;
// 使用更宽松的条件检查连接状态
const isReady = isPeerConnected || isDataChannelConnected || isChannelOpen || isConnected;
if (isReady && mode === 'send' && fileList.length > 0) {
console.log('连接已建立,初始化文件列表:', {
isPeerConnected,
isDataChannelConnected,
isChannelOpen,
isConnected,
fileListLength: fileList.length
});
// 数据通道第一次打开时进行初始化
syncFileListToReceiver(fileList, '数据通道初始化');
}
}, [connection.isPeerConnected, mode, syncFileListToReceiver]);
}, [connection.getConnectState().isPeerConnected, connection.getConnectState().isDataChannelConnected, connection.getConnectState().state, connection.getConnectState().isConnected, mode, fileList.length, syncFileListToReceiver]);
// 监听fileList大小变化并同步
useEffect(() => {
if (connection.isPeerConnected && mode === 'send' && pickupCode) {
console.log('fileList大小变化同步到接收方:', fileList.length);
const connectState = connection.getConnectState();
const isPeerConnected = connectState.isPeerConnected;
const isDataChannelConnected = connectState.isDataChannelConnected;
const isChannelOpen = connectState.state === 'open';
const isConnected = connectState.isConnected;
// 使用更宽松的条件检查连接状态
const isReady = isPeerConnected || isDataChannelConnected || isChannelOpen || isConnected;
if (isReady && mode === 'send' && pickupCode) {
console.log('fileList大小变化同步到接收方:', {
fileListLength: fileList.length,
isPeerConnected,
isDataChannelConnected,
isChannelOpen,
isConnected
});
syncFileListToReceiver(fileList, 'fileList大小变化');
}
}, [fileList.length, connection.isPeerConnected, mode, pickupCode, syncFileListToReceiver]);
}, [fileList.length, connection.getConnectState().isPeerConnected, connection.getConnectState().isDataChannelConnected, connection.getConnectState().state, connection.getConnectState().isConnected, mode, pickupCode, syncFileListToReceiver]);
// 监听接收方离开房间事件
useEffect(() => {
const connectState = connection.getConnectState();
const isPeerConnected = connectState.isPeerConnected;
const isConnected = connectState.isConnected;
// 当接收方离开房间时P2P连接断开清除发送方数据
if (mode === 'send' && pickupCode && !isPeerConnected && !isConnected) {
console.log('[WebRTCFileTransfer] 检测到接收方离开房间,清除发送方数据');
// 清除文件传输业务逻辑中的数据
clearSenderData();
// 清除文件状态管理器中的数据
clearFileStateData();
}
}, [connection.getConnectState().isPeerConnected, connection.getConnectState().isConnected, mode, pickupCode, clearSenderData, clearFileStateData, showToast]);
// 监听selectedFiles变化同步更新fileList并发送给接收方
useEffect(() => {
@@ -709,9 +682,7 @@ export const WebRTCFileTransfer: React.FC = () => {
onJoinRoom={joinRoom}
files={fileList}
onDownloadFile={handleDownloadRequest}
isConnected={isConnected}
isConnecting={isConnecting}
isWebSocketConnected={isWebSocketConnected}
downloadedFiles={downloadedFiles}
error={error}
onReset={resetConnection}

View File

@@ -0,0 +1,565 @@
"use client";
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/toast-simple';
import { useWebRTCStore } from '@/hooks/connection/state/webConnectStore';
import { IceServerConfig, useIceServersConfig } from '@/hooks/settings/useIceServersConfig';
import {
AlertTriangle,
Database,
Eye,
EyeOff,
Info,
Plus,
RotateCcw,
Save,
Server,
Settings,
Shield,
Trash2,
Wifi,
X
} from 'lucide-react';
import React, { useState } from 'react';
interface AddServerModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (config: Omit<IceServerConfig, 'id'>) => void;
validateServer: (config: Omit<IceServerConfig, 'id'>) => string[];
}
function AddServerModal({ isOpen, onClose, onSubmit, validateServer }: AddServerModalProps) {
const [formData, setFormData] = useState({
urls: '',
username: '',
credential: '',
type: 'stun' as 'stun' | 'turn',
enabled: true,
});
const [errors, setErrors] = useState<string[]>([]);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateServer(formData);
if (validationErrors.length > 0) {
setErrors(validationErrors);
return;
}
onSubmit(formData);
onClose();
// 重置表单
setFormData({
urls: '',
username: '',
credential: '',
type: 'stun',
enabled: true,
});
setErrors([]);
};
const handleTypeChange = (type: 'stun' | 'turn') => {
setFormData(prev => ({
...prev,
type,
username: type === 'stun' ? '' : prev.username,
credential: type === 'stun' ? '' : prev.credential,
}));
setErrors([]);
};
const handleClose = () => {
onClose();
setErrors([]);
setFormData({
urls: '',
username: '',
credential: '',
type: 'stun',
enabled: true,
});
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={(e) => {
// 点击背景关闭弹窗
if (e.target === e.currentTarget) {
handleClose();
}
}}
>
<div
className="bg-white rounded-xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto border border-gray-200 mx-4"
onClick={(e) => e.stopPropagation()} // 防止点击弹窗内容时关闭
>
<div className="p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg sm:text-xl font-semibold text-gray-900 flex items-center gap-2">
<Plus className="w-5 h-5 text-blue-600" />
<span className="hidden sm:inline">ICE服务器</span>
<span className="sm:hidden"></span>
</h3>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 服务器类型 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input
type="radio"
value="stun"
checked={formData.type === 'stun'}
onChange={(e) => handleTypeChange(e.target.value as 'stun' | 'turn')}
className="text-blue-600"
/>
<span className="text-sm">STUN</span>
</label>
<label className="flex items-center gap-2">
<input
type="radio"
value="turn"
checked={formData.type === 'turn'}
onChange={(e) => handleTypeChange(e.target.value as 'stun' | 'turn')}
className="text-blue-600"
/>
<span className="text-sm">TURN</span>
</label>
</div>
</div>
{/* 服务器地址 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<Input
value={formData.urls}
onChange={(e) => setFormData(prev => ({ ...prev, urls: e.target.value }))}
placeholder={formData.type === 'stun' ? 'stun:your-server.com:3478' : 'turn:your-server.com:3478'}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
{formData.type === 'stun' ?
'格式: stun:服务器地址:端口' :
'格式: turn:服务器地址:端口'
}
</p>
</div>
{/* TURN服务器认证信息 */}
{formData.type === 'turn' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<Input
value={formData.username}
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
placeholder="输入TURN服务器用户名"
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={formData.credential}
onChange={(e) => setFormData(prev => ({ ...prev, credential: e.target.value }))}
placeholder="输入TURN服务器密码"
className="w-full pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 min-h-[44px] min-w-[44px] flex items-center justify-center"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</>
)}
{/* 错误信息 */}
{errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-red-800"></p>
<ul className="text-sm text-red-700 mt-1 space-y-1">
{errors.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button type="submit" className="flex-1 min-h-[44px] justify-center">
<Save className="w-4 h-4 mr-2" />
</Button>
<Button
type="button"
variant="outline"
onClick={handleClose}
className="sm:w-auto min-h-[44px] justify-center"
>
</Button>
</div>
</form>
</div>
</div>
</div>
);
}
interface ServerItemProps {
server: IceServerConfig;
onRemove: (id: string) => void;
canRemove: boolean;
}
function ServerItem({ server, onRemove, canRemove }: ServerItemProps) {
return (
<div className="border rounded-lg p-3 sm:p-4 bg-white">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-2">
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
{server.type?.toUpperCase() || 'STUN'}
</span>
{server.isDefault && (
<span className="text-xs bg-green-100 text-green-600 px-2 py-1 rounded whitespace-nowrap">
</span>
)}
<span className="text-sm text-gray-700 break-all">
{server.urls}
</span>
</div>
{server.username && (
<p className="text-xs text-gray-500">
: {server.username}
</p>
)}
</div>
<div className="flex items-center justify-end sm:justify-start sm:ml-4">
{canRemove && (
<Button
variant="outline"
onClick={() => onRemove(server.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 min-h-[44px] min-w-[44px] px-3 py-2 sm:min-h-[36px] sm:min-w-[36px]"
>
<Trash2 className="w-4 h-4" />
<span className="ml-2 sm:hidden"></span>
</Button>
)}
</div>
</div>
</div>
);
}
export default function WebRTCSettings() {
const {
iceServers,
isLoading,
addIceServer,
removeIceServer,
resetToDefault,
validateServer,
} = useIceServersConfig();
const [showAddModal, setShowAddModal] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
const { showToast } = useToast();
// 获取WebRTC连接状态
const {
isConnected,
isConnecting,
isPeerConnected,
currentRoom
} = useWebRTCStore();
// 检查是否有活跃连接
const hasActiveConnection = isConnected || isConnecting || isPeerConnected;
const handleAddServer = (config: Omit<IceServerConfig, 'id'>) => {
try {
addIceServer(config);
showToast('ICE服务器添加成功', 'success');
} catch (error) {
showToast('添加失败,请重试', 'error');
}
};
const handleRemoveServer = (id: string) => {
setServerToDelete(id);
setShowDeleteDialog(true);
};
const confirmDeleteServer = () => {
if (serverToDelete) {
try {
removeIceServer(serverToDelete);
showToast('ICE服务器删除成功', 'success');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '删除失败';
showToast(errorMessage, 'error');
} finally {
setServerToDelete(null);
setShowDeleteDialog(false);
}
}
};
const cancelDeleteServer = () => {
setServerToDelete(null);
setShowDeleteDialog(false);
};
const handleResetToDefault = () => {
try {
resetToDefault();
showToast('已恢复默认配置', 'success');
} catch (error) {
showToast('恢复默认配置失败', 'error');
}
};
if (isLoading) {
return (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
);
}
return (
<div className="space-y-4 sm:space-y-6 px-4 sm:px-0">
{/* 头部 */}
<div className="text-center">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="p-2 sm:p-3 bg-blue-100 rounded-xl">
<Settings className="w-6 h-6 sm:w-8 sm:h-8 text-blue-600" />
</div>
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">WebRTC </h2>
</div>
<p className="text-sm sm:text-base text-gray-600">
STUN/TURN服务器以优化网络连接性能
</p>
</div>
{/* 隐私提示 */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 sm:p-4">
<div className="flex items-start gap-3">
<Shield className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-blue-900 mb-1 text-sm sm:text-base"></h3>
<p className="text-blue-800 text-xs sm:text-sm">
</p>
</div>
</div>
</div>
{/* 连接状态警告 */}
{hasActiveConnection && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 sm:p-4">
<div className="flex items-start gap-3">
<Wifi className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-amber-900 mb-1 text-sm sm:text-base"></h3>
<div className="text-amber-800 text-xs sm:text-sm">
<p>WebRTC连接</p>
{currentRoom && (
<p className="mt-1">
: <span className="font-mono text-xs">{currentRoom.code}</span>
({currentRoom.role === 'sender' ? '发送方' : '接收方'})
</p>
)}
<p className="mt-2 text-xs">
ICE服务器配置不会影响现有连接
</p>
</div>
</div>
</div>
</div>
)}
{/* ICE服务器列表 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-3 sm:p-4 border-b border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-3">
<Server className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
<div>
<h3 className="text-base sm:text-lg font-semibold text-gray-900">ICE </h3>
<p className="text-xs sm:text-sm text-gray-600">
{iceServers.length}
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Button
variant="outline"
onClick={handleResetToDefault}
className="flex items-center justify-center gap-2 min-h-[44px] px-4 py-2"
>
<RotateCcw className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button>
<Button
onClick={() => setShowAddModal(true)}
className="flex items-center justify-center gap-2 min-h-[44px] px-4 py-2"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button>
</div>
</div>
</div>
<div className="p-3 sm:p-4">
{/* 服务器列表 */}
<div className="space-y-3">
{iceServers.map((server) => (
<ServerItem
key={server.id}
server={server}
onRemove={handleRemoveServer}
canRemove={iceServers.length > 1}
/>
))}
</div>
{/* 空状态 */}
{iceServers.length === 0 && (
<div className="text-center py-8">
<Database className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500 mb-4">ICE服务器配置</p>
<Button
onClick={() => setShowAddModal(true)}
className="min-h-[44px] px-6"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</div>
</div>
{/* 配置说明 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
<div className="flex items-start gap-3">
<Info className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-gray-900 mb-2 text-sm sm:text-base"></h3>
<div className="space-y-3 text-xs sm:text-sm text-gray-700">
<div>
<h4 className="font-medium text-gray-900 mb-1">STUN:</h4>
<p className="text-gray-600">
IP地址和端口P2P连接
</p>
<p className="text-gray-600 mt-1">
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">stun:服务器地址:</code>
</p>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-1">TURN:</h4>
<p className="text-gray-600">
P2P连接失败时
</p>
<p className="text-gray-600 mt-1">
<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">turn:服务器地址:</code>
</p>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-1">:</h4>
<p className="text-gray-600">
ICE服务器
</p>
</div>
</div>
</div>
</div>
</div>
{/* 添加服务器弹窗 */}
<AddServerModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddServer}
validateServer={validateServer}
/>
{/* 删除确认对话框 */}
<ConfirmDialog
isOpen={showDeleteDialog}
onClose={cancelDeleteServer}
onConfirm={confirmDeleteServer}
title="删除ICE服务器"
message={(() => {
if (!serverToDelete) return "确定要删除这个ICE服务器吗";
const serverToDeleteInfo = iceServers.find(s => s.id === serverToDelete);
if (iceServers.length <= 1) {
return "这是最后一个ICE服务器删除后将无法建立WebRTC连接。确定要删除吗";
} else if (serverToDeleteInfo?.isDefault) {
return "这是一个默认ICE服务器删除后可能需要手动添加其他服务器。确定要删除吗";
} else {
return "确定要删除这个ICE服务器吗删除后将无法恢复。";
}
})()}
confirmText="删除"
cancelText="取消"
type="danger"
/>
</div>
);
}

View File

@@ -1,12 +1,12 @@
"use client";
import React, { useState, useCallback } from 'react';
import { useURLHandler } from '@/hooks/ui';
import { useWebRTCStore } from '@/hooks/ui/webRTCStore';
import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender';
import { WebRTCTextReceiver } from '@/components/webrtc/WebRTCTextReceiver';
import { Button } from '@/components/ui/button';
import { MessageSquare, Send, Download, X } from 'lucide-react';
import { WebRTCTextReceiver } from '@/components/webrtc/WebRTCTextReceiver';
import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender';
import { useWebRTCStore } from '@/hooks/connection/state/webConnectStore';
import { useURLHandler } from '@/hooks/ui';
import { Download, Send, X } from 'lucide-react';
import React, { useCallback, useState } from 'react';
export const WebRTCTextImageTransfer: React.FC = () => {
// 状态管理

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { AlertTriangle, Download, X, Chrome, Monitor } from 'lucide-react';
import { WebRTCSupport, getBrowserInfo, getRecommendedBrowsers } from '@/lib/webrtc-support';
import { AlertTriangle, Chrome, Download, Monitor, X } from 'lucide-react';
interface Props {
isOpen: boolean;
@@ -22,19 +21,21 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto border border-gray-100">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div className="flex items-center justify-between p-6 bg-gradient-to-r from-rose-50 to-orange-50 border-b border-orange-100 rounded-t-2xl">
<div className="flex items-center gap-3">
<AlertTriangle className="h-6 w-6 text-red-500" />
<h2 className="text-xl font-semibold text-gray-900">
WebRTC
<div className="p-2 bg-gradient-to-r from-orange-500 to-red-500 rounded-xl shadow-lg">
<AlertTriangle className="h-6 w-6 text-white" />
</div>
<h2 className="text-xl font-bold bg-gradient-to-r from-orange-600 to-red-600 bg-clip-text text-transparent">
</h2>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-1 transition-all duration-200"
>
<X className="h-5 w-5" />
</button>
@@ -43,15 +44,18 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props
{/* 内容 */}
<div className="p-6 space-y-6">
{/* 当前浏览器信息 */}
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="font-medium text-red-800 mb-2"></h3>
<div className="space-y-2 text-sm text-red-700">
<div>
<strong>:</strong> {browserInfo.name} {browserInfo.version}
<div className="bg-gradient-to-r from-rose-50 to-orange-50 border border-orange-200 rounded-xl p-5 shadow-sm">
<h3 className="font-semibold text-orange-800 mb-3 flex items-center gap-2">
<div className="w-2 h-2 bg-orange-500 rounded-full"></div>
</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between p-3 bg-white/70 rounded-lg">
<span className="text-gray-700"><strong>:</strong> {browserInfo.name} {browserInfo.version}</span>
</div>
<div>
<strong>WebRTC :</strong>
<span className="ml-1 px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
<div className="flex items-center justify-between p-3 bg-white/70 rounded-lg">
<span className="text-gray-700"><strong>WebRTC :</strong></span>
<span className="px-3 py-1 bg-gradient-to-r from-orange-500 to-red-500 text-white rounded-full text-xs font-medium shadow-sm">
</span>
</div>
@@ -59,59 +63,79 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props
</div>
{/* 缺失的功能 */}
<div className="space-y-3">
<h3 className="font-medium text-gray-900"></h3>
<div className="space-y-4">
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{webrtcSupport.missing.map((feature, index) => (
<div key={index} className="flex items-center gap-2 text-sm text-gray-600">
<div className="w-2 h-2 bg-red-400 rounded-full"></div>
{feature}
<div key={index} className="flex items-center gap-3 p-3 bg-gradient-to-r from-gray-50 to-blue-50 rounded-lg border border-gray-200">
<div className="w-2 h-2 bg-gradient-to-r from-orange-400 to-red-500 rounded-full"></div>
<span className="text-sm text-gray-700">{feature}</span>
</div>
))}
</div>
</div>
{/* 功能说明 */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="font-medium text-blue-800 mb-2"> WebRTC</h3>
<div className="space-y-2 text-sm text-blue-700">
<div className="flex items-start gap-2">
<Monitor className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-5 shadow-sm">
<h3 className="font-semibold text-blue-800 mb-4 flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
WebRTC
</h3>
<div className="space-y-4 text-sm">
<div className="flex items-start gap-3 p-3 bg-white/70 rounded-lg">
<div className="p-2 bg-gradient-to-r from-blue-500 to-indigo-500 rounded-lg shadow-sm">
<Monitor className="h-4 w-4 text-white" />
</div>
<div>
<strong>:</strong>
<div className="font-medium text-blue-800"></div>
<div className="text-blue-600"></div>
</div>
</div>
<div className="flex items-start gap-2">
<Download className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="flex items-start gap-3 p-3 bg-white/70 rounded-lg">
<div className="p-2 bg-gradient-to-r from-green-500 to-emerald-500 rounded-lg shadow-sm">
<Download className="h-4 w-4 text-white" />
</div>
<div>
<strong>:</strong>
<div className="font-medium text-blue-800"></div>
<div className="text-blue-600"></div>
</div>
</div>
<div className="flex items-start gap-2">
<Chrome className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="flex items-start gap-3 p-3 bg-white/70 rounded-lg">
<div className="p-2 bg-gradient-to-r from-purple-500 to-pink-500 rounded-lg shadow-sm">
<Chrome className="h-4 w-4 text-white" />
</div>
<div>
<strong>:</strong>
<div className="font-medium text-blue-800"></div>
<div className="text-blue-600"></div>
</div>
</div>
</div>
</div>
{/* 浏览器推荐 */}
<div className="space-y-3">
<h3 className="font-medium text-gray-900">使</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-4">
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
使
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{recommendedBrowsers.map((browser, index) => (
<div
key={index}
className="border border-gray-200 rounded-lg p-4 hover:border-blue-300 transition-colors cursor-pointer"
className="group border border-gray-200 rounded-xl p-4 hover:border-blue-300 hover:shadow-lg transition-all duration-200 cursor-pointer bg-gradient-to-br from-white to-gray-50 hover:from-blue-50 hover:to-indigo-50"
onClick={() => handleBrowserDownload(browser.downloadUrl)}
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-gray-900">{browser.name}</h4>
<p className="text-sm text-gray-600"> {browser.minVersion}</p>
<h4 className="font-semibold text-gray-900 group-hover:text-blue-800 transition-colors">{browser.name}</h4>
<p className="text-sm text-gray-600 group-hover:text-blue-600 transition-colors"> {browser.minVersion}</p>
</div>
<div className="p-2 bg-gradient-to-r from-blue-500 to-indigo-500 rounded-lg shadow-sm group-hover:shadow-md transition-all duration-200">
<Download className="h-5 w-5 text-white" />
</div>
<Download className="h-5 w-5 text-blue-500" />
</div>
</div>
))}
@@ -120,13 +144,16 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props
{/* 浏览器特定建议 */}
{browserInfo.recommendations && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h3 className="font-medium text-yellow-800 mb-2"></h3>
<ul className="space-y-1 text-sm text-yellow-700">
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border border-amber-200 rounded-xl p-5 shadow-sm">
<h3 className="font-semibold text-amber-800 mb-3 flex items-center gap-2">
<div className="w-2 h-2 bg-amber-500 rounded-full"></div>
</h3>
<ul className="space-y-2 text-sm">
{browserInfo.recommendations.map((recommendation, index) => (
<li key={index} className="flex items-start gap-2">
<div className="w-1.5 h-1.5 bg-yellow-400 rounded-full mt-2 flex-shrink-0"></div>
{recommendation}
<li key={index} className="flex items-start gap-3 p-3 bg-white/70 rounded-lg">
<div className="w-1.5 h-1.5 bg-gradient-to-r from-amber-400 to-orange-500 rounded-full mt-2 flex-shrink-0"></div>
<span className="text-amber-700">{recommendation}</span>
</li>
))}
</ul>
@@ -134,31 +161,33 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props
)}
{/* 技术详情(可折叠) */}
<details className="border border-gray-200 rounded-lg">
<summary className="p-3 cursor-pointer font-medium text-gray-900 hover:bg-gray-50">
<details className="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<summary className="p-4 cursor-pointer font-semibold text-gray-900 hover:bg-gradient-to-r hover:from-gray-50 hover:to-blue-50 transition-all duration-200">
🔧
</summary>
<div className="p-3 border-t border-gray-200 space-y-2 text-sm">
<div className="grid grid-cols-2 gap-4">
<div>
<strong>RTCPeerConnection:</strong>
<span className={`ml-2 px-2 py-1 rounded text-xs ${
webrtcSupport.details.rtcPeerConnection
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{webrtcSupport.details.rtcPeerConnection ? '支持' : '不支持'}
</span>
<div className="p-4 border-t border-gray-200 bg-gradient-to-r from-gray-50 to-blue-50 space-y-3 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-3 bg-white rounded-lg shadow-sm">
<div className="flex items-center justify-between">
<strong className="text-gray-700">RTCPeerConnection</strong>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${webrtcSupport.details.rtcPeerConnection
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white'
: 'bg-gradient-to-r from-orange-500 to-red-500 text-white'
}`}>
{webrtcSupport.details.rtcPeerConnection ? '支持' : '不支持'}
</span>
</div>
</div>
<div>
<strong>DataChannel:</strong>
<span className={`ml-2 px-2 py-1 rounded text-xs ${
webrtcSupport.details.dataChannel
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{webrtcSupport.details.dataChannel ? '支持' : '不支持'}
</span>
<div className="p-3 bg-white rounded-lg shadow-sm">
<div className="flex items-center justify-between">
<strong className="text-gray-700">DataChannel</strong>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${webrtcSupport.details.dataChannel
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white'
: 'bg-gradient-to-r from-orange-500 to-red-500 text-white'
}`}>
{webrtcSupport.details.dataChannel ? '支持' : '不支持'}
</span>
</div>
</div>
</div>
</div>
@@ -166,16 +195,16 @@ export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props
</div>
{/* 底部按钮 */}
<div className="flex justify-end gap-3 p-6 border-t border-gray-200">
<div className="flex justify-end gap-3 p-6 border-t border-gray-200 bg-gradient-to-r from-gray-50 to-blue-50 rounded-b-2xl">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
className="px-6 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded-lg transition-all duration-200 shadow-sm hover:shadow-md"
>
</button>
<button
onClick={() => handleBrowserDownload('https://www.google.com/chrome/')}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
className="px-6 py-2 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
Chrome
</button>

View File

@@ -73,6 +73,12 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
return;
}
// 检查房间是否已满
if (result.is_room_full) {
showToast('当前房间人数已满,正在传输中无法加入,请稍后再试', "error");
return;
}
// 检查发送方是否在线
if (!result.sender_online) {
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
@@ -172,6 +178,12 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
return;
}
// 检查房间是否已满
if (result.is_room_full) {
showToast('当前房间人数已满,正在传输中无法加入,请稍后再试', "error");
return;
}
// 检查发送方是否在线
if (!result.sender_online) {
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
@@ -221,6 +233,7 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
autoJoin();
}, [initialCode, desktopShare.isViewing, desktopShare.isConnecting, isJoiningRoom]); // 添加isJoiningRoom依赖
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">
@@ -328,8 +341,8 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
{/* 桌面显示区域 */}
{desktopShare.remoteStream ? (
<DesktopViewer
stream={desktopShare.remoteStream}
<DesktopViewer
stream={desktopShare.remoteStream}
isConnected={desktopShare.isViewing}
connectionCode={inputCode}
onDisconnect={handleStopViewing}
@@ -344,7 +357,7 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
<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>
)}

View File

@@ -1,12 +1,12 @@
"use client";
import React, { useState, useCallback, useEffect } from 'react';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { Button } from '@/components/ui/button';
import { Share, Monitor, Play, Square, Repeat } from 'lucide-react';
import { useToast } from '@/components/ui/toast-simple';
import { useDesktopShareBusiness } from '@/hooks/desktop-share';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import { Monitor, Repeat, Share, Square } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface WebRTCDesktopSenderProps {
className?: string;
@@ -20,6 +20,38 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
// 使用桌面共享业务逻辑
const desktopShare = useDesktopShareBusiness();
// 调试监控localStream状态变化
useEffect(() => {
console.log('[DesktopShareSender] localStream状态变化:', {
hasLocalStream: !!desktopShare.localStream,
streamId: desktopShare.localStream?.id,
trackCount: desktopShare.localStream?.getTracks().length,
isSharing: desktopShare.isSharing,
canStartSharing: desktopShare.canStartSharing,
});
}, [desktopShare.localStream, desktopShare.isSharing, desktopShare.canStartSharing]);
// 保持本地视频元素的引用
const localVideoRef = useRef<HTMLVideoElement | null>(null);
// 处理本地流变化,确保视频正确显示
useEffect(() => {
if (localVideoRef.current && desktopShare.localStream) {
console.log('[DesktopShareSender] 通过useEffect设置本地流到video元素');
localVideoRef.current.srcObject = desktopShare.localStream;
localVideoRef.current.muted = true;
localVideoRef.current.play().then(() => {
console.log('[DesktopShareSender] useEffect: 本地预览播放成功');
}).catch((e: Error) => {
console.warn('[DesktopShareSender] useEffect: 本地预览播放失败:', e);
});
} else if (localVideoRef.current && !desktopShare.localStream) {
console.log('[DesktopShareSender] 清除video元素的流');
localVideoRef.current.srcObject = null;
}
}, [desktopShare.localStream]);
// 通知父组件连接状态变化
useEffect(() => {
if (onConnectionChange && desktopShare.webRTCConnection) {
@@ -27,6 +59,38 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
}
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
// 监听连接状态变化当P2P连接断开时保持桌面共享状态
const prevPeerConnectedRef = useRef<boolean>(false);
useEffect(() => {
// 只有从连接状态变为断开状态时才处理
const wasPreviouslyConnected = prevPeerConnectedRef.current;
const isCurrentlyConnected = desktopShare.isPeerConnected;
// 更新ref
prevPeerConnectedRef.current = isCurrentlyConnected;
// 如果正在共享且从连接变为断开,保持桌面共享状态以便新用户加入
if (desktopShare.isSharing &&
wasPreviouslyConnected &&
!isCurrentlyConnected &&
desktopShare.connectionCode) {
console.log('[DesktopShareSender] 检测到P2P连接断开保持桌面共享状态等待新用户');
const handleDisconnect = async () => {
try {
await desktopShare.handlePeerDisconnect();
console.log('[DesktopShareSender] 已处理P2P断开保持桌面共享状态');
} catch (error) {
console.error('[DesktopShareSender] 处理P2P断开失败:', error);
}
};
handleDisconnect();
}
}, [desktopShare.isSharing, desktopShare.isPeerConnected, desktopShare.connectionCode]); // 移除handlePeerDisconnect依赖
// 复制房间代码
const copyCode = useCallback(async (code: string) => {
try {
@@ -38,15 +102,34 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
}
}, [showToast]);
// 创建房间
// 创建房间并开始桌面共享
const handleCreateRoomAndStart = useCallback(async () => {
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击创建房间并开始共享');
const roomCode = await desktopShare.createRoomAndStartSharing();
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 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);
@@ -62,15 +145,23 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
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');
// 分享失败时重置状态,让用户重新选择桌面
try {
// await desktopShare.resetSharing();
console.log('[DesktopShareSender] 已重置共享状态,用户可以重新选择桌面');
} catch (resetError) {
console.error('[DesktopShareSender] 重置共享状态失败:', resetError);
}
} finally {
setIsLoading(false);
}
@@ -81,15 +172,23 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
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');
// 切换桌面失败时重置状态,让用户重新选择桌面
try {
await desktopShare.resetSharing();
console.log('[DesktopShareSender] 已重置共享状态,用户可以重新选择桌面');
} catch (resetError) {
console.error('[DesktopShareSender] 重置共享状态失败:', resetError);
}
} finally {
setIsLoading(false);
}
@@ -100,10 +199,10 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
try {
setIsLoading(true);
console.log('[DesktopShareSender] 用户点击停止桌面共享');
await desktopShare.stopSharing();
console.log('[DesktopShareSender] 桌面共享停止成功');
showToast('桌面共享已停止', 'success');
} catch (error) {
console.error('[DesktopShareSender] 停止桌面共享失败:', error);
@@ -131,8 +230,8 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
<p className="text-sm text-slate-600"></p>
</div>
</div>
<ConnectionStatus
<ConnectionStatus
currentRoom={desktopShare.connectionCode ? { code: desktopShare.connectionCode, role: 'sender' } : null}
/>
</div>
@@ -143,9 +242,9 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-4"></h3>
<p className="text-slate-600 mb-8"></p>
<Button
onClick={handleCreateRoom}
onClick={handleCreateRoomAndStart}
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"
>
@@ -157,7 +256,7 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
) : (
<>
<Share className="w-5 h-5 mr-2" />
</>
)}
</Button>
@@ -177,8 +276,8 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
<p className="text-sm text-slate-600">: {desktopShare.connectionCode}</p>
</div>
</div>
<ConnectionStatus
<ConnectionStatus
currentRoom={{ code: desktopShare.connectionCode, role: 'sender' }}
/>
</div>
@@ -191,70 +290,66 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
<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 className="flex items-center space-x-2">
<Button
onClick={handleSwitchDesktop}
disabled={isLoading}
variant="outline"
size="sm"
className="text-slate-700 border-slate-300"
>
<Repeat className="w-4 h-4 mr-1" />
</Button>
<Button
onClick={handleStopSharing}
disabled={isLoading}
variant="destructive"
size="sm"
className="bg-red-500 hover:bg-red-600 text-white"
>
<Square className="w-4 h-4 mr-1" />
</Button>
</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>
{/* 本地预览区域(显示正在共享的内容) */}
{desktopShare.isSharing && (
<div className="bg-black rounded-xl overflow-hidden relative">
{/* 共享状态指示器 */}
<div className="absolute top-2 left-2 z-10">
<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="text-xs font-medium"></span>
</div>
</div>
{desktopShare.localStream ? (
<video
ref={localVideoRef}
key={desktopShare.localStream.id} // 使用key确保重新渲染
autoPlay
playsInline
muted
className="w-full aspect-video object-contain bg-black"
style={{ minHeight: '300px' }}
/>
) : (
<div className="w-full flex items-center justify-center text-white bg-black" style={{ minHeight: '300px' }}>
<div className="text-center">
<Monitor className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">...</p>
</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>
)}

View File

@@ -1,11 +1,13 @@
"use client";
import React, { useState, useCallback } from 'react';
import { ConnectionStatus } from '@/components/ConnectionStatus';
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';
import { useReadConnectState } from '@/hooks/connection/state/useWebConnectStateManager';
import { TransferProgressTracker } from '@/lib/transfer-utils';
import { Archive, Clock, Download, FileText, Image, Music, Video, Zap } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
interface FileInfo {
id: string;
@@ -14,6 +16,8 @@ interface FileInfo {
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
transferSpeed?: number; // bytes per second
startTime?: number; // 传输开始时间
}
const getFileIcon = (mimeType: string) => {
@@ -36,9 +40,6 @@ interface WebRTCFileReceiveProps {
onJoinRoom: (code: string) => void;
files: FileInfo[];
onDownloadFile: (fileId: string) => void;
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected?: boolean;
downloadedFiles?: Map<string, File>;
error?: string | null;
onReset?: () => void;
@@ -49,9 +50,6 @@ export function WebRTCFileReceive({
onJoinRoom,
files,
onDownloadFile,
isConnected,
isConnecting,
isWebSocketConnected = false,
downloadedFiles,
error = null,
onReset,
@@ -60,10 +58,17 @@ export function WebRTCFileReceive({
const [pickupCode, setPickupCode] = useState('');
const [isValidating, setIsValidating] = useState(false);
const { showToast } = useToast();
// 用于跟踪传输进度的trackers
const transferTrackers = useRef<Map<string, TransferProgressTracker>>(new Map());
// 使用传入的取件码或本地状态的取件码
const displayPickupCode = propPickupCode || pickupCode;
const { getConnectState } = useReadConnectState();
// 验证取件码是否存在
const validatePickupCode = async (code: string): Promise<boolean> => {
try {
@@ -76,7 +81,16 @@ export function WebRTCFileReceive({
console.log('验证响应:', { status: response.status, data });
if (!response.ok || !data.success) {
const errorMessage = data.message || '取件码验证失败';
let errorMessage = data.message || '取件码验证失败';
// 特殊处理房间人数已满的情况
if (data.message?.includes('房间人数已满') || data.message?.includes('正在传输中无法加入')) {
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
} else if (data.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (data.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
// 显示toast错误提示
showToast(errorMessage, 'error');
@@ -85,6 +99,14 @@ export function WebRTCFileReceive({
return false;
}
// 检查房间是否已满
if (data.is_room_full) {
const errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
showToast(errorMessage, 'error');
console.log('房间已满:', errorMessage);
return false;
}
console.log('取件码验证成功:', data.room);
return true;
} catch (error) {
@@ -123,7 +145,7 @@ export function WebRTCFileReceive({
// 当验证失败时重置输入状态
React.useEffect(() => {
if (error && !isConnecting && !isConnected && !isValidating) {
if (error && !getConnectState().isConnecting && !getConnectState().isConnected && !isValidating) {
// 延迟重置,确保用户能看到错误信息
const timer = setTimeout(() => {
console.log('重置取件码输入');
@@ -132,10 +154,10 @@ export function WebRTCFileReceive({
return () => clearTimeout(timer);
}
}, [error, isConnecting, isConnected, isValidating]);
}, [error, getConnectState, isValidating]);
// 如果已经连接但没有文件,显示等待界面
if ((isConnected || isConnecting) && files.length === 0) {
if ((getConnectState().isConnected || getConnectState().isConnecting) && files.length === 0) {
return (
<div>
{/* 功能标题和状态 */}
@@ -145,6 +167,7 @@ export function WebRTCFileReceive({
<Download className="w-5 h-5 text-white" />
</div>
<div>
{getConnectState().isWebSocketConnected}
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {displayPickupCode}</p>
</div>
@@ -167,9 +190,9 @@ export function WebRTCFileReceive({
{/* 连接状态指示器 */}
<div className="flex items-center justify-center space-x-4 mb-6">
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-500 animate-spin'}`}></div>
<span className={`text-sm font-medium ${isConnected ? 'text-emerald-600' : 'text-orange-600'}`}>
{isConnected ? '连接已建立' : '连接中...'}
<div className={`w-3 h-3 rounded-full mr-2 ${getConnectState().isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-500 animate-spin'}`}></div>
<span className={`text-sm font-medium ${getConnectState().isConnected ? 'text-emerald-600' : 'text-orange-600'}`}>
{getConnectState().isConnected ? '连接已建立' : '连接中...'}
</span>
</div>
</div>
@@ -225,16 +248,44 @@ export function WebRTCFileReceive({
const isDownloading = file.status === 'downloading';
const isCompleted = file.status === 'completed';
const hasDownloadedFile = downloadedFiles?.has(file.id);
const currentProgress = file.progress;
console.log('文件状态:', {
fileName: file.name,
status: file.status,
progress: file.progress,
isDownloading,
currentProgress
isDownloading
});
// 计算传输进度信息
let transferInfo = null;
let currentProgress = 0; // 使用稳定的进度值
if (isDownloading && file) {
const fileKey = `${file.name}-${file.size}`;
let tracker = transferTrackers.current.get(fileKey);
// 如果tracker不存在创建一个新的
if (!tracker) {
tracker = new TransferProgressTracker(file.size);
transferTrackers.current.set(fileKey, tracker);
}
// 更新传输进度
const transferredBytes = (file.progress / 100) * file.size;
const progressInfo = tracker.update(transferredBytes);
transferInfo = progressInfo;
currentProgress = progressInfo.percentage; // 使用稳定的百分比
} else {
// 如果不在传输中,使用原始进度值
currentProgress = file.progress;
}
// 清理已完成的tracker
if (file.status === 'completed') {
const fileKey = `${file.name}-${file.size}`;
transferTrackers.current.delete(fileKey);
}
return (
<div key={file.id} className="bg-gradient-to-r from-slate-50 to-blue-50 border border-slate-200 rounded-xl p-3 sm:p-4 hover:shadow-md transition-all duration-200">
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-3 gap-3">
@@ -249,13 +300,33 @@ export function WebRTCFileReceive({
<p className="text-xs text-emerald-600 font-medium"> </p>
)}
{isDownloading && (
<p className="text-xs text-blue-600 font-medium"> ...{currentProgress.toFixed(1)}%</p>
<div className="space-y-1">
{/* 传输速度和剩余时间信息 */}
{transferInfo && (
<div className="flex items-center space-x-3">
<div className="flex items-center gap-1 text-xs text-blue-600">
<Zap className="w-3 h-3 flex-shrink-0" />
<span className="w-3 font-mono text-right">{transferInfo.speed.displaySpeed}</span>
<span className='w-2'/>
<span className="w-3">{transferInfo.speed.unit}</span>
<span className='w-3'/>
</div>
{transferInfo.remainingTime.seconds < Infinity && (
<div className="flex items-center gap-1 text-xs text-slate-600">
<Clock className="w-3 h-3 flex-shrink-0" />
<span></span>
<span className="w-3 font-mono text-right">{transferInfo.remainingTime.display}</span>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
<Button
onClick={() => onDownloadFile(file.id)}
disabled={!isConnected || isDownloading}
disabled={!getConnectState().isConnected || isDownloading}
className={`px-6 py-2 rounded-lg font-medium shadow-lg transition-all duration-200 hover:shadow-xl ${
hasDownloadedFile
? 'bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 text-white'
@@ -272,7 +343,9 @@ export function WebRTCFileReceive({
{(isDownloading || isCompleted) && currentProgress > 0 && (
<div className="mt-3 space-y-2">
<div className="flex justify-between text-sm text-slate-600">
<span>{hasDownloadedFile ? '传输完成' : '正在传输...'}</span>
<span>
{hasDownloadedFile ? '传输完成' : '正在传输...'}
</span>
<span className="font-medium">{currentProgress.toFixed(1)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
@@ -326,7 +399,7 @@ export function WebRTCFileReceive({
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-emerald-500 focus:ring-emerald-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
maxLength={6}
disabled={isValidating || isConnecting}
disabled={isValidating || getConnectState().isConnecting}
/>
<div className="absolute inset-x-0 -bottom-4 sm:-bottom-6 flex justify-center space-x-1 sm:space-x-2">
{[...Array(6)].map((_, i) => (
@@ -350,14 +423,14 @@ export function WebRTCFileReceive({
<Button
type="submit"
className="w-full h-10 sm:h-12 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-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"
disabled={pickupCode.length !== 6 || isValidating || isConnecting}
disabled={pickupCode.length !== 6 || isValidating || getConnectState().isConnecting}
>
{isValidating ? (
<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>
) : isConnecting ? (
) : getConnectState().isConnecting ? (
<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>

View File

@@ -1,10 +1,11 @@
"use client";
import React, { useState, useRef, useCallback } from 'react';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Upload, FileText, Image, Video, Music, Archive, X } from 'lucide-react';
import { Upload, FileText, Image, Video, Music, Archive, X, Clock, Zap } from 'lucide-react';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import { TransferProgressTracker, formatTransferSpeed, formatTime } from '@/lib/transfer-utils';
interface FileInfo {
@@ -14,6 +15,8 @@ interface FileInfo {
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
transferSpeed?: number; // bytes per second
startTime?: number; // 传输开始时间
}
const getFileIcon = (mimeType: string) => {
@@ -65,6 +68,9 @@ export function WebRTCFileUpload({
}: WebRTCFileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 用于跟踪传输进度的trackers
const transferTrackers = useRef<Map<string, TransferProgressTracker>>(new Map());
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -197,9 +203,38 @@ export function WebRTCFileUpload({
// 查找对应的文件信息(包含状态和进度)
const fileInfo = fileList.find(f => f.name === file.name && f.size === file.size);
const isTransferringThisFile = fileInfo?.status === 'downloading';
const currentProgress = fileInfo?.progress || 0;
const fileStatus = fileInfo?.status || 'ready';
// 计算传输进度信息
let transferInfo = null;
let currentProgress = 0; // 使用稳定的进度值
if (isTransferringThisFile && fileInfo) {
const fileKey = `${file.name}-${file.size}`;
let tracker = transferTrackers.current.get(fileKey);
// 如果tracker不存在创建一个新的
if (!tracker) {
tracker = new TransferProgressTracker(file.size);
transferTrackers.current.set(fileKey, tracker);
}
// 更新传输进度
const transferredBytes = (fileInfo.progress / 100) * file.size;
const progressInfo = tracker.update(transferredBytes);
transferInfo = progressInfo;
currentProgress = progressInfo.percentage; // 使用稳定的百分比
} else {
// 如果不在传输中,使用原始进度值
currentProgress = fileInfo?.progress || 0;
}
// 清理已完成的tracker
if (fileStatus === 'completed') {
const fileKey = `${file.name}-${file.size}`;
transferTrackers.current.delete(fileKey);
}
return (
<div
key={`${file.name}-${file.size}-${index}`}
@@ -227,6 +262,26 @@ export function WebRTCFileUpload({
</div>
)}
</div>
{/* 传输速度和剩余时间信息 */}
{transferInfo && (
<div className="flex items-center space-x-3 mt-1">
<div className="flex items-center gap-1 text-xs text-blue-600">
<Zap className="w-3 h-3 flex-shrink-0" />
<span className="w-3 font-mono text-right">{transferInfo.speed.displaySpeed}</span>
<span className='w-2'/>
<span className="w-3">{transferInfo.speed.unit}</span>
<span className='w-3'/>
</div>
{transferInfo.remainingTime.seconds < Infinity && (
<div className="flex items-center gap-1 text-xs text-slate-600">
<Clock className="w-3 h-3 flex-shrink-0" />
<span></span>
<span className="w-3 font-mono text-right">{transferInfo.remainingTime.display}</span>
</div>
)}
</div>
)}
</div>
</div>
<Button
@@ -245,7 +300,9 @@ export function WebRTCFileUpload({
<div className="px-3 sm:px-4 pb-3 sm:pb-4">
<div className="space-y-2">
<div className="flex justify-between text-xs text-slate-600">
<span>{fileStatus === 'downloading' ? '正在发送...' : '发送完成'}</span>
<span>
{fileStatus === 'downloading' ? '正在发送...' : '发送完成'}
</span>
<span className="font-medium">{currentProgress.toFixed(1)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">

View File

@@ -1,14 +1,14 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useSharedWebRTCManager } from '@/hooks/connection';
import { useTextTransferBusiness } from '@/hooks/text-transfer';
import { useFileTransferBusiness } from '@/hooks/file-transfer';
import { ConnectionStatus } from '@/components/ConnectionStatus';
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';
import { useConnectManager } from '@/hooks/connection';
import { useFileTransferBusiness } from '@/hooks/file-transfer';
import { useTextTransferBusiness } from '@/hooks/text-transfer';
import { Download, Image, MessageSquare } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
interface WebRTCTextReceiverProps {
initialCode?: string;
@@ -37,7 +37,9 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
const hasTriedAutoConnect = useRef(false);
// 创建共享连接
const connection = useSharedWebRTCManager();
const connection = useConnectManager();
const {getConnectState} = connection;
// 使用共享连接创建业务层
const textTransfer = useTextTransferBusiness(connection);
@@ -61,7 +63,7 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
if (onConnectionChange) {
onConnectionChange(connection);
}
}, [onConnectionChange, connection.isConnected, connection.isConnecting, connection.isPeerConnected]);
}, [onConnectionChange, getConnectState().isConnected, getConnectState().isConnecting, getConnectState().isPeerConnected]);
// 是否有任何错误
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
@@ -141,7 +143,23 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
const roomData = await response.json();
if (!response.ok) {
throw new Error(roomData.error || '房间不存在或已过期');
let errorMessage = roomData.error || '房间不存在或已过期';
// 特殊处理房间人数已满的情况
if (roomData.message?.includes('房间人数已满') || roomData.message?.includes('正在传输中无法加入')) {
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
} else if (roomData.message?.includes('expired')) {
errorMessage = '房间已过期,请联系发送方重新创建';
} else if (roomData.message?.includes('not found')) {
errorMessage = '房间不存在,请检查取件码是否正确';
}
throw new Error(errorMessage);
}
// 检查房间是否已满
if (roomData.is_room_full) {
throw new Error('当前房间人数已满,正在传输中无法加入,请稍后再试');
}
console.log('=== 房间验证成功 ===', roomData);
@@ -320,7 +338,7 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
<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 ?
{getConnectState().isPeerConnected ?
'等待对方发送文字内容...' :
'等待连接建立...'}
</p>

View File

@@ -1,14 +1,14 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useSharedWebRTCManager } from '@/hooks/connection';
import { useTextTransferBusiness } from '@/hooks/text-transfer';
import { useFileTransferBusiness } from '@/hooks/file-transfer';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast-simple';
import { MessageSquare, Image, Send, Copy } from 'lucide-react';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import { useConnectManager } from '@/hooks/connection';
import { useFileTransferBusiness } from '@/hooks/file-transfer';
import { useTextTransferBusiness } from '@/hooks/text-transfer';
import { Image, MessageSquare, Send } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
interface WebRTCTextSenderProps {
onRestart?: () => void;
@@ -31,8 +31,10 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 创建共享连接
const connection = useSharedWebRTCManager();
const connection = useConnectManager();
const { getConnectState } = connection;
// 使用共享连接创建业务层
const textTransfer = useTextTransferBusiness(connection);
const fileTransfer = useFileTransferBusiness(connection);
@@ -43,9 +45,6 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
// 只需要连接一次,因为使用的是共享连接
await connection.connect(code, role);
}, [connection]);
// 是否有任何连接
const hasAnyConnection = textTransfer.isConnected || fileTransfer.isConnected;
// 是否正在连接
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
@@ -55,10 +54,8 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
if (onConnectionChange) {
onConnectionChange(connection);
}
}, [onConnectionChange, connection.isConnected, connection.isConnecting, connection.isPeerConnected]);
}, [onConnectionChange, getConnectState().isConnected, getConnectState().isConnecting, getConnectState().isPeerConnected]);
// 是否有任何错误
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
// 重新开始
const restart = () => {
@@ -140,7 +137,7 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
// 如果有初始文本,发送它
if (currentText) {
setTimeout(() => {
if (connection.isPeerConnected && textTransfer.isConnected) {
if (getConnectState().isPeerConnected && textTransfer.isConnected) {
// 发送实时文本同步
textTransfer.sendTextSync(currentText);
@@ -171,7 +168,7 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
textarea.style.height = `${newHeight}px`;
// 实时同步文本内容如果P2P连接已建立
if (connection.isPeerConnected && textTransfer.isConnected) {
if (getConnectState().isPeerConnected && textTransfer.isConnected) {
// 发送实时文本同步
textTransfer.sendTextSync(value);
@@ -214,10 +211,10 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
}]);
// 发送文件
if (connection.isPeerConnected && fileTransfer.isConnected) {
if (getConnectState().isPeerConnected && fileTransfer.isConnected) {
fileTransfer.sendFile(file);
showToast('图片发送中...', "success");
} else if (!connection.isPeerConnected) {
} else if (!getConnectState().isPeerConnected) {
showToast('等待对方加入P2P网络...', "error");
} else {
showToast('请先连接到房间', "error");
@@ -362,19 +359,18 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
)}
</div>
</div>
<textarea
ref={textareaRef}
value={textInput}
onChange={handleTextInputChange}
onPaste={handlePaste}
disabled={!connection.isPeerConnected}
placeholder={connection.isPeerConnected
disabled={!getConnectState().isPeerConnected}
placeholder={getConnectState().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
getConnectState().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"
}`}
@@ -386,9 +382,9 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
onClick={() => fileInputRef.current?.click()}
variant="outline"
size="sm"
disabled={!connection.isPeerConnected}
disabled={!getConnectState().isPeerConnected}
className={`flex items-center space-x-1 ${
!connection.isPeerConnected ? 'cursor-not-allowed opacity-50' : ''
!getConnectState().isPeerConnected ? 'cursor-not-allowed opacity-50' : ''
}`}
>
<Image className="w-4 h-4" />

View File

@@ -1,6 +1,8 @@
// 连接相关的hooks
export { useConnectionState } from './useConnectionState';
export { useConnectManager } from './useConnectManager';
export { useRoomConnection } from './useRoomConnection';
export { useSharedWebRTCManager } from './useSharedWebRTCManager';
export { useWebRTCManager } from './useWebRTCManager';
export { useWebRTCSupport } from './useWebRTCSupport';

View File

@@ -0,0 +1,80 @@
import { useCallback } from 'react';
import { Role } from '../types';
import { useWebRTCStore, type WebConnectState } from './webConnectStore';
/**
* WebRTC 状态管理器
* 负责连接状态的统一管理
*/
export interface IWebConnectStateManager {
// 获取当前状态
getState: () => Readonly<WebConnectState>;
// 更新状态
updateState: (updates: Partial<WebConnectState>) => void;
// 设置当前房间
setCurrentRoom: (room: { code: string; role: Role } | null) => void;
// 重置到初始状态
resetToInitial: () => void;
// 检查是否已连接到指定房间
isConnectedToRoom: (roomCode: string, role: Role) => boolean;
}
export interface IUseReadConnectState {
getConnectState: () => Readonly<WebConnectState>;
}
export function useReadConnectState(): IUseReadConnectState {
const webrtcStore = useWebRTCStore();
const getConnectState = useCallback((): Readonly<WebConnectState> => {
return webrtcStore;
}, [webrtcStore]);
return {
getConnectState
};
}
/**
* WebRTC 状态管理 Hook
* 封装对 webRTCStore 的操作,提供状态更新和查询的统一接口
*/
export function useWebConnectStateManager(): IWebConnectStateManager {
const webrtcStore = useWebRTCStore();
const getState = useCallback((): WebConnectState => {
return webrtcStore;
}, [webrtcStore]);
const updateState = useCallback((updates: Partial<WebConnectState>) => {
webrtcStore.updateState(updates);
}, [webrtcStore]);
const setCurrentRoom = useCallback((room: { code: string; role: Role } | null) => {
webrtcStore.setCurrentRoom(room);
}, [webrtcStore]);
const resetToInitial = useCallback(() => {
webrtcStore.resetToInitial();
}, [webrtcStore]);
const isConnectedToRoom = useCallback((roomCode: string, role: Role) => {
return webrtcStore.currentRoom?.code === roomCode &&
webrtcStore.currentRoom?.role === role &&
webrtcStore.isConnected;
}, [webrtcStore]);
return {
getState,
updateState,
setCurrentRoom,
resetToInitial,
isConnectedToRoom,
};
}

View File

@@ -0,0 +1,61 @@
import { create } from 'zustand';
import { Role } from '../types';
export interface WebConnectState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean;
isJoinedRoom: boolean;
isDataChannelConnected: boolean;
isMediaStreamConnected: boolean;
currentConnectType: 'webrtc' | 'websocket';
currentIsLocalNetWork: boolean; // 可选,表示当前是否在局域网内
state: RTCDataChannelState;
stateMsg: string | null;
error: string | null;
canRetry: boolean; // 新增:是否可以重试
currentRoom: { code: string; role: Role } | null;
}
interface WebRTCStore extends WebConnectState {
updateState: (updates: Partial<WebConnectState>) => void;
setCurrentRoom: (room: { code: string; role: Role } | null) => void;
reset: () => void;
resetToInitial: () => void; // 新增:完全重置到初始状态
}
const initialState: WebConnectState = {
isConnected: false,
isConnecting: false,
currentIsLocalNetWork: false,
isWebSocketConnected: false,
isJoinedRoom: false,
isPeerConnected: false,
error: null,
canRetry: false, // 初始状态下不需要重试
currentRoom: null,
stateMsg: null,
isDataChannelConnected: false,
isMediaStreamConnected: false,
currentConnectType: 'webrtc',
state: 'closed'
};
export const useWebRTCStore = create<WebRTCStore>((set) => ({
...initialState,
updateState: (updates) => set((state) => {
console.log('Updating WebRTC state:', updates);
return { ...state, ...updates };
}),
setCurrentRoom: (room) => set((state) => ({
...state,
currentRoom: room,
})),
reset: () => set(initialState),
resetToInitial: () => set(initialState), // 完全重置到初始状态
}));

View File

@@ -0,0 +1,124 @@
import { WebConnectState } from "./state/webConnectStore";
// 消息和数据处理器类型
export type MessageHandler = (message: IWebMessage) => void;
export type DataHandler = (data: ArrayBuffer) => void;
// 角色类型
export type Role = 'sender' | 'receiver';
export type ConnectType = 'webrtc' | 'websocket';
// 对外包装类型 暴露接口
export interface IRegisterEventHandler {
registerMessageHandler: (channel: string, handler: MessageHandler) => () => void;
registerDataHandler: (channel: string, handler: DataHandler) => () => void;
}
export interface IGetConnectState {
getConnectState: () => WebConnectState;
}
/***
*
* 对外包装类型 暴露接口
*
*/
// WebRTC 连接接口
export interface IWebConnection extends IRegisterEventHandler, IGetConnectState {
connectType: ConnectType;
// 操作方法
connect: (roomCode: string, role: Role) => Promise<void>;
disconnect: () => void;
retry: () => Promise<void>;
sendMessage: (message: IWebMessage, channel?: string) => boolean;
sendData: (data: ArrayBuffer) => boolean;
// 工具方法
getConnectState: () => WebConnectState;
isConnectedToRoom: (roomCode: string, role: Role) => boolean;
// 当前房间信息
currentRoom: { code: string; role: Role } | null;
// 媒体轨道方法
addTrack: (track: MediaStreamTrack, stream: MediaStream) => RTCRtpSender | null;
removeTrack: (sender: RTCRtpSender) => void;
onTrack: (callback: (event: RTCTrackEvent) => void) => void;
getPeerConnection: () => RTCPeerConnection | null;
createOfferNow: () => Promise<boolean>;
// 断开连接回调
setOnDisconnectCallback: (callback: () => void) => void;
}
// 消息类型
export interface IWebMessage {
type: string;
payload: any;
channel?: string;
}
/***
*
* 数据通道类型
* WebRTC 数据通道管理器
* 负责数据通道的创建和管理
*/
export interface WebRTCDataChannelManager extends IGetConnectState {
// 创建数据通道
createDataChannel: (pc: RTCPeerConnection, role: Role, isReconnect?: boolean) => void;
// 发送消息
sendMessage: (message: IWebMessage, channel?: string) => boolean;
// 发送二进制数据
sendData: (data: ArrayBuffer) => boolean;
// 处理数据通道消息
handleDataChannelMessage: (event: MessageEvent) => void;
}
/**
* WebRTC 媒体轨道管理器
* 负责媒体轨道的添加和移除
*/
export interface WebRTCTrackManager {
// 添加媒体轨道
addTrack: (track: MediaStreamTrack, stream: MediaStream) => RTCRtpSender | null;
// 移除媒体轨道
removeTrack: (sender: RTCRtpSender) => void;
// 设置轨道处理器
onTrack: (handler: (event: RTCTrackEvent) => void) => void;
// 请求重新协商(通知 Core 层需要重新创建 Offer
requestOfferRenegotiation: () => Promise<boolean>;
// 触发重新协商
triggerRenegotiation: () => Promise<boolean>;
// 内部方法,供核心连接管理器调用
setPeerConnection: (pc: RTCPeerConnection | null) => void;
setWebSocket: (ws: WebSocket | null) => void;
}

View File

@@ -0,0 +1,358 @@
import { getWsUrl } from '@/lib/config';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useReadConnectState } from './state/useWebConnectStateManager';
import { WebConnectState } from "./state/webConnectStore";
import { ConnectType, DataHandler, IGetConnectState, IRegisterEventHandler, IWebConnection, IWebMessage, MessageHandler, Role } from "./types";
import { useSharedWebRTCManagerImpl } from './webrtc/useSharedWebRTCManager';
import { useWebSocketConnection } from './ws/useWebSocketConnection';
/**
* 连接管理器 - 统一管理 WebSocket 和 WebRTC 连接
* 提供统一的连接接口,内部可以在不同传输方式之间切换
* 统一管理 WebSocket 连接,为 WebRTC 和 WebSocket 传输提供共享的 WebSocket 实例
*/
export function useConnectManager(): IWebConnection & IRegisterEventHandler & IGetConnectState {
// 当前连接类型
const [currentConnectType, setCurrentConnectType] = useState<ConnectType>('webrtc');
// 统一的 WebSocket 连接引用
const wsRef = useRef<WebSocket | null>(null);
// 当前房间信息
const currentRoomRef = useRef<{ code: string; role: Role } | null>(null);
// 连接实例 - 初始化时不传入 WebSocket
const wsConnection = useWebSocketConnection();
const webrtcConnection = useSharedWebRTCManagerImpl();
// 当前活跃连接的引用 - 默认使用 WebRTC
const currentConnectionRef = useRef<IWebConnection>(webrtcConnection);
const { getConnectState: innerState } = useReadConnectState();
// 确保连接引用与连接类型保持一致
useEffect(() => {
const targetConnection = currentConnectType === 'webrtc' ? webrtcConnection : wsConnection;
if (currentConnectionRef.current !== targetConnection) {
console.log('[ConnectManager] 🔄 同步连接引用到:', currentConnectType);
currentConnectionRef.current = targetConnection;
}
}, [currentConnectType, webrtcConnection, wsConnection]);
// 连接状态管理
const connectionStateRef = useRef<WebConnectState>({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
isDataChannelConnected: false,
isMediaStreamConnected: false,
isJoinedRoom: false,
currentConnectType: 'webrtc',
state: 'closed',
error: null,
canRetry: false,
currentRoom: null,
stateMsg: null,
currentIsLocalNetWork: false
});
// 更新连接状态
const updateConnectionState = useCallback((updates: Partial<WebConnectState>) => {
connectionStateRef.current = {
...connectionStateRef.current,
...updates
};
}, []);
// 创建并管理 WebSocket 连接
const createWebSocketConnection = useCallback(async (roomCode: string, role: Role) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
console.log('[ConnectManager] 已存在 WebSocket 连接,先断开');
wsRef.current.close();
}
try {
// 构建 WebSocket URL
const baseWsUrl = getWsUrl();
if (!baseWsUrl) {
throw new Error('WebSocket URL未配置');
}
// 构建完整的WebSocket URL
const wsUrl = `${baseWsUrl}/api/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`;
console.log('[ConnectManager] 创建 WebSocket 连接:', wsUrl);
const ws = new WebSocket(wsUrl);
// 设置二进制数据类型为 ArrayBuffer避免默认的 Blob 类型
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
currentRoomRef.current = { code: roomCode, role };
// WebSocket 事件处理
ws.onopen = () => {
console.log('[ConnectManager] WebSocket 连接成功');
updateConnectionState({
isWebSocketConnected: true,
error: null
});
};
ws.onerror = (error) => {
console.error('[ConnectManager] WebSocket 连接错误:', error);
updateConnectionState({
isWebSocketConnected: false,
error: 'WebSocket 连接失败'
});
};
ws.onclose = (event) => {
console.log('[ConnectManager] WebSocket 连接关闭:', event.code, event.reason);
updateConnectionState({
isWebSocketConnected: false,
error: event.wasClean ? null : 'WebSocket 连接意外断开'
});
};
return ws;
} catch (error) {
console.error('[ConnectManager] 创建 WebSocket 连接失败:', error);
updateConnectionState({
isWebSocketConnected: false,
error: '无法建立 WebSocket 连接'
});
throw error;
}
}, [updateConnectionState]);
// 获取 WebSocket 连接
const getWebSocketConnection = useCallback(() => {
return wsRef.current;
}, []);
// 切换连接类型
const switchConnectionType = useCallback((type: ConnectType) => {
console.log('[ConnectManager] 切换连接类型:', currentConnectType, '->', type);
// 如果当前有连接,先断开
if (connectionStateRef.current.isConnected) {
currentConnectionRef.current.disconnect();
}
// 切换到新的连接类型
setCurrentConnectType(type);
currentConnectionRef.current = type === 'websocket' ? wsConnection : webrtcConnection;
updateConnectionState({
currentConnectType: type,
error: null
});
}, [currentConnectType, wsConnection, webrtcConnection, updateConnectionState]);
// 连接到房间
const connect = useCallback(async (roomCode: string, role: Role) => {
console.log('[ConnectManager] 连接到房间:', roomCode, '角色:', role, '类型:', currentConnectType);
updateConnectionState({
isConnecting: true,
error: null,
currentRoom: { code: roomCode, role }
});
try {
// 首先创建统一的 WebSocket 连接
const ws = await createWebSocketConnection(roomCode, role);
if (currentConnectType === 'webrtc') {
// 将 WebSocket 注入到 WebRTC 连接中
webrtcConnection.injectWebSocket(ws);
currentConnectionRef.current = webrtcConnection;
await currentConnectionRef.current.connect(roomCode, role);
} else {
// WebSocket 连接也使用统一的 WebSocket 实例
wsConnection.injectWebSocket(ws);
currentConnectionRef.current = wsConnection;
await currentConnectionRef.current.connect(roomCode, role);
}
} catch (error) {
console.error('[ConnectManager] 连接失败:', error);
updateConnectionState({
isConnecting: false,
error: error instanceof Error ? error.message : '连接失败'
});
}
}, [currentConnectType, createWebSocketConnection, webrtcConnection, wsConnection, updateConnectionState]);
// 断开连接
const disconnect = useCallback(() => {
console.log('[ConnectManager] 断开连接');
currentConnectionRef.current.disconnect();
// 断开 WebSocket 连接
if (wsRef.current) {
wsRef.current.close(1000, '用户主动断开');
wsRef.current = null;
}
currentRoomRef.current = null;
updateConnectionState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
isDataChannelConnected: false,
isMediaStreamConnected: false,
error: null,
canRetry: false,
currentRoom: null
});
}, [updateConnectionState]);
// 重试连接
const retry = useCallback(async () => {
console.log('[ConnectManager] 重试连接');
if (connectionStateRef.current.currentRoom) {
const { code, role } = connectionStateRef.current.currentRoom;
await connect(code, role);
}
}, [connect]);
// 发送消息
const sendMessage = useCallback((message: IWebMessage, channel?: string) => {
return currentConnectionRef.current.sendMessage(message, channel);
}, []);
// 发送数据
const sendData = useCallback((data: ArrayBuffer) => {
return currentConnectionRef.current.sendData(data);
}, []);
// 获取连接状态
const getConnectState = useCallback((): WebConnectState => {
// 合并当前连接的状态和管理器的状态
return innerState();
}, [innerState]);
// 检查是否连接到指定房间
const isConnectedToRoom = useCallback((roomCode: string, role: Role) => {
return currentConnectionRef.current.isConnectedToRoom(roomCode, role);
}, []);
// 媒体轨道方法(代理到当前连接)
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
return currentConnectionRef.current.addTrack(track, stream);
}, []);
const removeTrack = useCallback((sender: RTCRtpSender) => {
currentConnectionRef.current.removeTrack(sender);
}, []);
const onTrack = useCallback((callback: (event: RTCTrackEvent) => void) => {
console.log('[ConnectManager] 🎧 设置 onTrack 处理器,当前连接类型:', currentConnectType);
console.log('[ConnectManager] 当前连接引用:', currentConnectionRef.current === webrtcConnection ? 'WebRTC' : 'WebSocket');
currentConnectionRef.current.onTrack(callback);
}, [currentConnectType, webrtcConnection]);
const getPeerConnection = useCallback(() => {
return currentConnectionRef.current.getPeerConnection();
}, []);
const createOfferNow = useCallback(async () => {
return currentConnectionRef.current.createOfferNow();
}, []);
// 设置断开连接回调
const setOnDisconnectCallback = useCallback((callback: () => void) => {
currentConnectionRef.current.setOnDisconnectCallback(callback);
}, []);
// 扩展方法:切换连接类型
const switchToWebSocket = useCallback(() => {
switchConnectionType('websocket');
}, [switchConnectionType]);
const switchToWebRTC = useCallback(() => {
switchConnectionType('webrtc');
}, [switchConnectionType]);
// 获取连接统计信息
const getConnectionStats = useCallback(() => {
const state = getConnectState();
return {
currentType: currentConnectType,
isConnected: state.isConnected,
hasWebSocket: state.isWebSocketConnected,
hasWebRTC: state.isPeerConnected,
hasDataChannel: state.isDataChannelConnected,
hasMediaStream: state.isMediaStreamConnected,
room: state.currentRoom,
error: state.error,
canRetry: state.canRetry
};
}, [currentConnectType, innerState]);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
console.log('[DataChannelManager] 注册消息处理器:', channel);
const webrtcConnectionUninstall = webrtcConnection.registerMessageHandler(channel, handler);
const wsConnectionUninstall = wsConnection.registerMessageHandler(channel, handler);
return () => {
console.log('[DataChannelManager] 取消注册消息处理器:', channel);
webrtcConnectionUninstall();
wsConnectionUninstall();
};
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
console.log('[DataChannelManager] 注册数据处理器:', channel);
const webrtcConnectionUninstall = webrtcConnection.registerDataHandler(channel, handler);
const wsConnectionUninstall = wsConnection.registerDataHandler(channel, handler);
return () => {
console.log('[DataChannelManager] 取消注册数据处理器:', channel);
webrtcConnectionUninstall();
wsConnectionUninstall();
};
}, []);
return {
connectType: currentConnectType,
connect,
disconnect,
retry,
sendMessage,
sendData,
registerMessageHandler,
registerDataHandler,
getConnectState,
isConnectedToRoom,
currentRoom: connectionStateRef.current.currentRoom,
addTrack,
removeTrack,
onTrack,
getPeerConnection,
createOfferNow,
setOnDisconnectCallback,
// 扩展方法
switchToWebSocket,
switchToWebRTC,
getConnectionStats,
} as IWebConnection & {
switchToWebSocket: () => void;
switchToWebRTC: () => void;
getConnectionStats: () => any;
};
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useToast } from '@/components/ui/toast-simple';
import { useEffect, useState } from 'react';
interface UseConnectionStateProps {
isWebSocketConnected: boolean;

View File

@@ -1,8 +1,9 @@
import { useState, useCallback } from 'react';
import { useToast } from '@/components/ui/toast-simple';
import { useCallback, useState } from 'react';
import { Role } from './types';
interface UseRoomConnectionProps {
connect: (code: string, role: 'sender' | 'receiver') => void;
connect: (code: string, role: Role) => void;
isConnecting: boolean;
isConnected: boolean;
}
@@ -38,6 +39,11 @@ export const useRoomConnection = ({ connect, isConnecting, isConnected }: UseRoo
throw new Error(errorMessage);
}
// 检查房间是否已满
if (result.is_room_full) {
throw new Error('当前房间人数已满,正在传输中无法加入');
}
if (!result.sender_online) {
throw new Error('发送方不在线,请确认取件码是否正确或联系发送方');
}
@@ -54,6 +60,8 @@ export const useRoomConnection = ({ connect, isConnecting, isConnected }: UseRoo
return '房间不存在,请检查取件码';
} else if (error.message.includes('HTTP 500')) {
return '服务器错误,请稍后重试';
} else if (error.message.includes('房间人数已满') || error.message.includes('正在传输中无法加入')) {
return '当前房间人数已满,正在传输中无法加入,请稍后再试';
} else {
return error.message;
}

View File

@@ -1,858 +0,0 @@
import { useState, useRef, useCallback } from 'react';
import { getWsUrl } from '@/lib/config';
import { useWebRTCStore } from '../ui/webRTCStore';
// 基础连接状态
interface WebRTCState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean; // 新增P2P连接状态
error: string | null;
canRetry: boolean; // 新增:是否可以重试
}
// 消息类型
interface WebRTCMessage {
type: string;
payload: any;
channel?: string;
}
// 消息和数据处理器类型
type MessageHandler = (message: WebRTCMessage) => void;
type DataHandler = (data: ArrayBuffer) => void;
// WebRTC 连接接口
export interface WebRTCConnection {
// 状态
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean; // 新增P2P连接状态
error: string | null;
canRetry: boolean; // 新增:是否可以重试
// 操作方法
connect: (roomCode: string, role: 'sender' | 'receiver') => Promise<void>;
disconnect: () => void;
retry: () => Promise<void>; // 新增:重试连接方法
sendMessage: (message: WebRTCMessage, channel?: string) => boolean;
sendData: (data: ArrayBuffer) => boolean;
// 处理器注册
registerMessageHandler: (channel: string, handler: MessageHandler) => () => void;
registerDataHandler: (channel: string, handler: DataHandler) => () => void;
// 工具方法
getChannelState: () => RTCDataChannelState;
isConnectedToRoom: (roomCode: string, role: 'sender' | 'receiver') => boolean;
// 当前房间信息
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>;
}
/**
* 共享 WebRTC 连接管理器
* 创建单一的 WebRTC 连接实例,供多个业务模块共享使用
*/
export function useSharedWebRTCManager(): WebRTCConnection {
// 使用全局状态 store
const webrtcStore = useWebRTCStore();
const wsRef = useRef<WebSocket | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const dcRef = useRef<RTCDataChannel | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// 当前连接的房间信息
const currentRoom = useRef<{ code: string; role: 'sender' | 'receiver' } | null>(null);
// 用于跟踪是否是用户主动断开连接
const isUserDisconnecting = useRef<boolean>(false);
// 多通道消息处理器
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
// STUN 服务器配置 - 使用更稳定的服务器
const STUN_SERVERS = [
{ urls: 'stun:stun.l.google.com:19302' },
{ 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>) => {
webrtcStore.updateState(updates);
}, [webrtcStore]);
// 清理连接
const cleanup = useCallback(() => {
console.log('[SharedWebRTC] 清理连接');
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (dcRef.current) {
dcRef.current.close();
dcRef.current = null;
}
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
currentRoom.current = null;
isUserDisconnecting.current = false; // 重置主动断开标志
}, []);
// 创建 Offer
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
try {
console.log('[SharedWebRTC] 🎬 开始创建offer当前轨道数量:', pc.getSenders().length);
const offer = await pc.createOffer({
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 (超时发送)');
}
}, 3000);
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 📤 发送 offer (ICE收集完成)');
}
} else {
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[SharedWebRTC] 📤 发送 offer (ICE收集完成)');
}
}
};
}
} catch (error) {
console.error('[SharedWebRTC] ❌ 创建 offer 失败:', error);
updateState({ error: '创建连接失败', isConnecting: false, canRetry: true });
}
}, [updateState]);
// 处理数据通道消息
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data) as WebRTCMessage;
console.log('[SharedWebRTC] 收到消息:', message.type, message.channel || 'default');
// 根据通道分发消息
if (message.channel) {
const handler = messageHandlers.current.get(message.channel);
if (handler) {
handler(message);
}
} else {
// 兼容旧版本,广播给所有处理器
messageHandlers.current.forEach(handler => handler(message));
}
} catch (error) {
console.error('[SharedWebRTC] 解析消息失败:', error);
}
} else if (event.data instanceof ArrayBuffer) {
console.log('[SharedWebRTC] 收到数据:', event.data.byteLength, 'bytes');
// 数据优先发给文件传输处理器
const fileHandler = dataHandlers.current.get('file-transfer');
if (fileHandler) {
fileHandler(event.data);
} else {
// 如果没有文件处理器,发给第一个处理器
const firstHandler = dataHandlers.current.values().next().value;
if (firstHandler) {
firstHandler(event.data);
}
}
}
}, []);
// 连接到房间
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
console.log('[SharedWebRTC] 🚀 开始连接到房间:', roomCode, role);
// 如果正在连接中,避免重复连接
if (webrtcStore.isConnecting) {
console.warn('[SharedWebRTC] ⚠️ 正在连接中,跳过重复连接请求');
return;
}
// 清理之前的连接
cleanup();
currentRoom.current = { code: roomCode, role };
webrtcStore.setCurrentRoom({ code: roomCode, role });
updateState({ isConnecting: true, error: null });
// 重置主动断开标志
isUserDisconnecting.current = false;
// 注意不在这里设置超时因为WebSocket连接很快
// WebRTC连接的建立是在后续添加轨道时进行的
try {
console.log('[SharedWebRTC] 🔧 创建PeerConnection...');
// 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: STUN_SERVERS,
iceCandidatePoolSize: 10,
});
pcRef.current = pc;
// 连接 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,
isConnecting: false, // WebSocket连接成功即表示初始连接完成
isConnected: true // 可以开始后续操作
});
};
ws.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
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');
} else {
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是stable:', pc.signalingState);
}
break;
case '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) {
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);
updateState({ error: message.error, isConnecting: false, canRetry: true });
break;
case 'disconnection':
console.log('[SharedWebRTC] 🔌 对方主动断开连接');
// 对方断开连接的处理
updateState({
isPeerConnected: false,
isConnected: false, // 添加这个状态
error: '对方已离开房间',
canRetry: true
});
// 清理P2P连接但保持WebSocket连接允许重新连接
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
if (dcRef.current) {
dcRef.current.close();
dcRef.current = null;
}
break;
default:
console.warn('[SharedWebRTC] ⚠️ 未知消息类型:', message.type);
}
} catch (error) {
console.error('[SharedWebRTC] ❌ 处理信令消息失败:', error);
updateState({ error: '信令处理失败: ' + error, isConnecting: false, canRetry: true });
}
};
ws.onerror = (error) => {
console.error('[SharedWebRTC] ❌ WebSocket 错误:', error);
updateState({ error: 'WebSocket连接失败', isConnecting: false, canRetry: true });
};
ws.onclose = (event) => {
console.log('[SharedWebRTC] 🔌 WebSocket 连接已关闭, 代码:', event.code, '原因:', event.reason);
updateState({ isWebSocketConnected: false });
// 检查是否是用户主动断开
if (isUserDisconnecting.current) {
console.log('[SharedWebRTC] ✅ 用户主动断开,正常关闭');
// 用户主动断开时不显示错误消息
return;
}
// 只有在非正常关闭且不是用户主动断开时才显示错误
if (event.code !== 1000 && event.code !== 1001) { // 非正常关闭
updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '连接意外断开'}`, isConnecting: false, canRetry: true });
}
};
// PeerConnection 事件处理
pc.onicecandidate = (event) => {
if (event.candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'ice-candidate',
payload: event.candidate
}));
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, canRetry: true });
break;
case 'disconnected':
console.log('[SharedWebRTC] 🔌 ICE连接断开');
break;
case 'closed':
console.log('[SharedWebRTC] 🚫 ICE连接已关闭');
break;
}
};
pc.onconnectionstatechange = () => {
console.log('[SharedWebRTC] 🔗 WebRTC连接状态变化:', pc.connectionState);
switch (pc.connectionState) {
case 'connecting':
console.log('[SharedWebRTC] 🔄 WebRTC正在连接中...');
updateState({ isPeerConnected: false });
break;
case 'connected':
console.log('[SharedWebRTC] 🎉 WebRTC P2P连接已完全建立可以进行媒体传输');
updateState({ isPeerConnected: true, error: null, canRetry: false });
break;
case 'failed':
// 只有在数据通道也未打开的情况下才认为连接真正失败
const currentDc = dcRef.current;
if (!currentDc || currentDc.readyState !== 'open') {
console.error('[SharedWebRTC] ❌ WebRTC连接失败数据通道未建立');
updateState({ error: 'WebRTC连接失败请检查网络设置或重试', isPeerConnected: false, canRetry: true });
} else {
console.log('[SharedWebRTC] ⚠️ WebRTC连接状态为failed但数据通道正常忽略此状态');
}
break;
case 'disconnected':
console.log('[SharedWebRTC] 🔌 WebRTC连接已断开');
updateState({ isPeerConnected: false });
break;
case 'closed':
console.log('[SharedWebRTC] 🚫 WebRTC连接已关闭');
updateState({ isPeerConnected: false });
break;
}
};
// 数据通道处理
if (role === 'sender') {
const dataChannel = pc.createDataChannel('shared-channel', {
ordered: true,
maxRetransmits: 3
});
dcRef.current = dataChannel;
dataChannel.onopen = () => {
console.log('[SharedWebRTC] 数据通道已打开 (发送方)');
updateState({ isPeerConnected: true, error: null, isConnecting: false, canRetry: false });
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[SharedWebRTC] 数据通道错误:', error);
// 获取更详细的错误信息
let errorMessage = '数据通道连接失败';
let shouldRetry = false;
// 根据数据通道状态提供更具体的错误信息
switch (dataChannel.readyState) {
case 'connecting':
errorMessage = '数据通道正在连接中,请稍候...';
shouldRetry = true;
break;
case 'closing':
errorMessage = '数据通道正在关闭,连接即将断开';
break;
case 'closed':
errorMessage = '数据通道已关闭P2P连接失败';
shouldRetry = true;
break;
default:
// 检查PeerConnection状态
const pc = pcRef.current;
if (pc) {
switch (pc.connectionState) {
case 'failed':
errorMessage = 'P2P连接失败可能是网络防火墙阻止了连接请尝试切换网络或使用VPN';
shouldRetry = true;
break;
case 'disconnected':
errorMessage = 'P2P连接已断开网络可能不稳定';
shouldRetry = true;
break;
default:
errorMessage = '数据通道连接失败,可能是网络环境受限';
shouldRetry = true;
}
}
}
console.error(`[SharedWebRTC] 数据通道详细错误 - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
updateState({
error: errorMessage,
isConnecting: false,
isPeerConnected: false, // 数据通道出错时P2P连接肯定不可用
canRetry: shouldRetry // 设置是否可以重试
});
};
} else {
pc.ondatachannel = (event) => {
const dataChannel = event.channel;
dcRef.current = dataChannel;
dataChannel.onopen = () => {
console.log('[SharedWebRTC] 数据通道已打开 (接收方)');
updateState({ isPeerConnected: true, error: null, isConnecting: false, canRetry: false });
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[SharedWebRTC] 数据通道错误 (接收方):', error);
// 获取更详细的错误信息
let errorMessage = '数据通道连接失败';
let shouldRetry = false;
// 根据数据通道状态提供更具体的错误信息
switch (dataChannel.readyState) {
case 'connecting':
errorMessage = '数据通道正在连接中,请稍候...';
shouldRetry = true;
break;
case 'closing':
errorMessage = '数据通道正在关闭,连接即将断开';
break;
case 'closed':
errorMessage = '数据通道已关闭P2P连接失败';
shouldRetry = true;
break;
default:
// 检查PeerConnection状态
const pc = pcRef.current;
if (pc) {
switch (pc.connectionState) {
case 'failed':
errorMessage = 'P2P连接失败可能是网络防火墙阻止了连接请尝试切换网络或使用VPN';
shouldRetry = true;
break;
case 'disconnected':
errorMessage = 'P2P连接已断开网络可能不稳定';
shouldRetry = true;
break;
default:
errorMessage = '数据通道连接失败,可能是网络环境受限';
shouldRetry = true;
}
}
}
console.error(`[SharedWebRTC] 数据通道详细错误 (接收方) - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
updateState({
error: errorMessage,
isConnecting: false,
isPeerConnected: false, // 数据通道出错时P2P连接肯定不可用
canRetry: shouldRetry // 设置是否可以重试
});
};
};
}
// 设置轨道接收处理(对于接收方)
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({
error: error instanceof Error ? error.message : '连接失败',
isConnecting: false,
canRetry: true
});
}
}, [updateState, cleanup, createOffer, handleDataChannelMessage, webrtcStore.isConnecting, webrtcStore.isConnected]);
// 断开连接
const disconnect = useCallback(() => {
console.log('[SharedWebRTC] 主动断开连接');
// 设置主动断开标志
isUserDisconnecting.current = true;
// 在断开之前通知对方
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({
type: 'disconnection',
payload: { reason: '用户主动断开' }
}));
console.log('[SharedWebRTC] 📤 已通知对方断开连接');
} catch (error) {
console.warn('[SharedWebRTC] 发送断开通知失败:', error);
}
}
// 清理连接
cleanup();
// 主动断开时,将状态完全重置为初始状态(没有任何错误或消息)
webrtcStore.resetToInitial();
}, [cleanup, webrtcStore]);
// 重试连接
const retry = useCallback(async () => {
const room = currentRoom.current;
if (!room) {
console.warn('[SharedWebRTC] 没有当前房间信息,无法重试');
updateState({ error: '无法重试连接:缺少房间信息', canRetry: false });
return;
}
console.log('[SharedWebRTC] 🔄 重试连接到房间:', room.code, room.role);
// 清理当前连接
cleanup();
// 重新连接
await connect(room.code, room.role);
}, [cleanup, connect, updateState]);
// 发送消息
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[SharedWebRTC] 数据通道未准备就绪');
return false;
}
try {
const messageWithChannel = channel ? { ...message, channel } : message;
dataChannel.send(JSON.stringify(messageWithChannel));
console.log('[SharedWebRTC] 发送消息:', message.type, channel || 'default');
return true;
} catch (error) {
console.error('[SharedWebRTC] 发送消息失败:', error);
return false;
}
}, []);
// 发送二进制数据
const sendData = useCallback((data: ArrayBuffer) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[SharedWebRTC] 数据通道未准备就绪');
return false;
}
try {
dataChannel.send(data);
console.log('[SharedWebRTC] 发送数据:', data.byteLength, 'bytes');
return true;
} catch (error) {
console.error('[SharedWebRTC] 发送数据失败:', error);
return false;
}
}, []);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
console.log('[SharedWebRTC] 注册消息处理器:', channel);
messageHandlers.current.set(channel, handler);
return () => {
console.log('[SharedWebRTC] 取消注册消息处理器:', channel);
messageHandlers.current.delete(channel);
};
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
console.log('[SharedWebRTC] 注册数据处理器:', channel);
dataHandlers.current.set(channel, handler);
return () => {
console.log('[SharedWebRTC] 取消注册数据处理器:', channel);
dataHandlers.current.delete(channel);
};
}, []);
// 获取数据通道状态
const getChannelState = useCallback(() => {
return dcRef.current?.readyState || 'closed';
}, []);
// 检查是否已连接到指定房间
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
return currentRoom.current?.code === roomCode &&
currentRoom.current?.role === role &&
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');
// 检查WebSocket连接状态只有连接后才尝试设置
if (!webrtcStore.isWebSocketConnected) {
console.log('[SharedWebRTC] WebSocket未连接等待连接建立...');
return;
}
// 延迟设置等待PeerConnection准备就绪
let retryCount = 0;
const maxRetries = 30; // 最多重试30次即3秒
const checkAndSetTrackHandler = () => {
const currentPc = pcRef.current;
if (currentPc) {
console.log('[SharedWebRTC] ✅ PeerConnection 已准备就绪设置onTrack处理器');
currentPc.ontrack = handler;
} else {
retryCount++;
if (retryCount < maxRetries) {
// 只在偶数次重试时输出日志,减少日志数量
if (retryCount % 2 === 0) {
console.log(`[SharedWebRTC] ⏳ 等待PeerConnection准备就绪... (尝试: ${retryCount}/${maxRetries})`);
}
setTimeout(checkAndSetTrackHandler, 100);
} else {
console.error('[SharedWebRTC] ❌ PeerConnection 长时间未准备就绪,停止重试');
}
}
};
checkAndSetTrackHandler();
return;
}
console.log('[SharedWebRTC] ✅ 立即设置onTrack处理器');
pc.ontrack = handler;
}, [webrtcStore.isWebSocketConnected]);
// 获取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: webrtcStore.isConnected,
isConnecting: webrtcStore.isConnecting,
isWebSocketConnected: webrtcStore.isWebSocketConnected,
isPeerConnected: webrtcStore.isPeerConnected,
error: webrtcStore.error,
canRetry: webrtcStore.canRetry,
// 操作方法
connect,
disconnect,
retry,
sendMessage,
sendData,
// 处理器注册
registerMessageHandler,
registerDataHandler,
// 工具方法
getChannelState,
isConnectedToRoom,
// 媒体轨道方法
addTrack,
removeTrack,
onTrack,
getPeerConnection,
createOfferNow,
// 当前房间信息
currentRoom: currentRoom.current,
};
}

View File

@@ -1,195 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { WebRTCManager } from '../webrtc/WebRTCManager';
import { WebRTCConnectionState, WebRTCMessage, MessageHandler, DataHandler } from '../webrtc/types';
import { WebRTCConnection } from './useSharedWebRTCManager';
interface WebRTCManagerConfig {
dataChannelName?: string;
enableLogging?: boolean;
iceServers?: RTCIceServer[];
iceCandidatePoolSize?: number;
chunkSize?: number;
maxRetries?: number;
retryDelay?: number;
ackTimeout?: number;
}
/**
* 新的 WebRTC 管理器 Hook
* 替代原有的 useSharedWebRTCManager提供更好的架构和错误处理
*/
export function useWebRTCManager(config: WebRTCManagerConfig = {}): WebRTCConnection {
const managerRef = useRef<WebRTCManager | null>(null);
const [state, setState] = useState<WebRTCConnectionState>({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
canRetry: false,
currentRoom: null,
});
// 初始化管理器
useEffect(() => {
if (!managerRef.current) {
managerRef.current = new WebRTCManager(config);
// 监听状态变化
managerRef.current.on('state-change', (event: any) => {
setState(prev => ({ ...prev, ...event.state }));
});
}
return () => {
if (managerRef.current) {
managerRef.current.disconnect();
managerRef.current = null;
}
};
}, [config]);
// 连接
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
if (!managerRef.current) {
throw new Error('WebRTC 管理器未初始化');
}
return managerRef.current.connect(roomCode, role);
}, []);
// 断开连接
const disconnect = useCallback(() => {
if (!managerRef.current) return;
managerRef.current.disconnect();
}, []);
// 重试连接
const retry = useCallback(async () => {
if (!managerRef.current) {
throw new Error('WebRTC 管理器未初始化');
}
return managerRef.current.retry();
}, []);
// 发送消息
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
if (!managerRef.current) return false;
return managerRef.current.sendMessage(message, channel);
}, []);
// 发送数据
const sendData = useCallback((data: ArrayBuffer) => {
if (!managerRef.current) return false;
return managerRef.current.sendData(data);
}, []);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
if (!managerRef.current) return () => {};
return managerRef.current.registerMessageHandler(channel, handler);
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
if (!managerRef.current) return () => {};
return managerRef.current.registerDataHandler(channel, handler);
}, []);
// 添加媒体轨道
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
if (!managerRef.current) return null;
return managerRef.current.addTrack(track, stream);
}, []);
// 移除媒体轨道
const removeTrack = useCallback((sender: RTCRtpSender) => {
if (!managerRef.current) return;
managerRef.current.removeTrack(sender);
}, []);
// 设置轨道处理器
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void) => {
if (!managerRef.current) return;
managerRef.current.onTrack(handler);
}, []);
// 获取 PeerConnection
const getPeerConnection = useCallback(() => {
if (!managerRef.current) return null;
return managerRef.current.getPeerConnection();
}, []);
// 立即创建 offer
const createOfferNow = useCallback(async () => {
if (!managerRef.current) return false;
return managerRef.current.createOfferNow();
}, []);
// 获取数据通道状态
const getChannelState = useCallback(() => {
if (!managerRef.current) return 'closed';
return managerRef.current.getChannelState();
}, []);
// 检查是否已连接到指定房间
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
if (!managerRef.current) return false;
return managerRef.current.isConnectedToRoom(roomCode, role);
}, []);
return {
// 状态
isConnected: state.isConnected,
isConnecting: state.isConnecting,
isWebSocketConnected: state.isWebSocketConnected,
isPeerConnected: state.isPeerConnected,
error: state.error,
canRetry: state.canRetry,
// 操作方法
connect,
disconnect,
retry,
sendMessage,
sendData,
// 处理器注册
registerMessageHandler,
registerDataHandler,
// 工具方法
getChannelState,
isConnectedToRoom,
// 媒体轨道方法
addTrack,
removeTrack,
onTrack,
getPeerConnection,
createOfferNow,
// 当前房间信息
currentRoom: state.currentRoom,
};
}
/**
* 迁移辅助 Hook - 提供向后兼容性
* 可以逐步将现有代码迁移到新的架构
*/
export function useWebRTCMigration() {
const newManager = useWebRTCManager();
// 注意:这里需要先创建一个包装器来兼容旧的接口
// 暂时注释掉,避免循环依赖
// const oldManager = useSharedWebRTCManager();
return {
newManager,
// oldManager, // 暂时禁用
// 可以添加迁移工具函数
migrateState: () => {
// 将旧状态迁移到新状态
console.log('状态迁移功能待实现');
},
};
}

View File

@@ -0,0 +1,83 @@
import { useCallback } from 'react';
import { useWebConnectStateManager } from '../state/useWebConnectStateManager';
import { IGetConnectState, IRegisterEventHandler, IWebConnection } from '../types';
import { useWebRTCConnectionCore } from './useWebRTCConnectionCore';
import { useWebRTCDataChannelManager } from './useWebRTCDataChannelManager';
import { useWebRTCTrackManager } from './useWebRTCTrackManager';
/**
* 共享 WebRTC 连接管理器
* 创建单一的 WebRTC 连接实例,供多个业务模块共享使用
* 整合所有模块,提供统一的接口
*
* webrtc 实现 - 初始化时不需要 WebSocket通过 injectWebSocket 动态注入
*
*/
export function useSharedWebRTCManagerImpl(): IWebConnection & IRegisterEventHandler & IGetConnectState & {
injectWebSocket: (ws: WebSocket) => void;
} {
// 创建各个管理器实例
const stateManager = useWebConnectStateManager();
const dataChannelManager = useWebRTCDataChannelManager(stateManager);
const trackManager = useWebRTCTrackManager(stateManager);
const connectionCore = useWebRTCConnectionCore(
stateManager,
dataChannelManager,
trackManager
);
// 创建 createOfferNow 方法
const createOfferNow = useCallback(async () => {
const pc = connectionCore.getPeerConnection();
const ws = connectionCore.getWebSocket();
if (!pc || !ws) {
console.error('[SharedWebRTC] PeerConnection 或 WebSocket 不可用');
return false;
}
try {
return await connectionCore.createOfferForMedia();
} catch (error) {
console.error('[SharedWebRTC] 创建 offer 失败:', error);
return false;
}
}, [connectionCore, trackManager]);
// 返回统一的接口,保持与当前 API 一致
return {
// 状态
connectType: 'webrtc',
// 操作方法
connect: connectionCore.connect,
disconnect: () => connectionCore.disconnect(true),
retry: connectionCore.retry,
sendMessage: dataChannelManager.sendMessage,
sendData: dataChannelManager.sendData,
// 处理器注册
registerMessageHandler: dataChannelManager.registerMessageHandler,
registerDataHandler: dataChannelManager.registerDataHandler,
// 工具方法
getConnectState: stateManager.getState,
isConnectedToRoom: stateManager.isConnectedToRoom,
// 媒体轨道方法
addTrack: trackManager.addTrack,
removeTrack: trackManager.removeTrack,
onTrack: trackManager.onTrack,
getPeerConnection: connectionCore.getPeerConnection,
createOfferNow,
// 断开连接回调
setOnDisconnectCallback: connectionCore.setOnDisconnectCallback,
// 当前房间信息
currentRoom: connectionCore.getCurrentRoom(),
// WebSocket 注入方法
injectWebSocket: connectionCore.injectWebSocket,
};
}

View File

@@ -0,0 +1,737 @@
import { useCallback, useRef } from 'react';
import { getIceServersConfig } from '../../settings/useIceServersConfig';
import { IWebConnectStateManager } from '../state/useWebConnectStateManager';
import { Role, WebRTCDataChannelManager, WebRTCTrackManager } from '../types';
/**
* WebRTC 核心连接管理器
* 负责基础的 WebRTC 连接管理
*/
export interface WebRTCConnectionCore {
// 连接到房间
connect: (roomCode: string, role: Role) => Promise<void>;
// 断开连接
disconnect: (shouldNotifyDisconnect?: boolean) => void;
// 重试连接
retry: () => Promise<void>;
// 获取 PeerConnection 实例
getPeerConnection: () => RTCPeerConnection | null;
// 获取 WebSocket 实例
getWebSocket: () => WebSocket | null;
// 获取当前房间信息
getCurrentRoom: () => { code: string; role: Role } | null;
// 设置断开连接回调
setOnDisconnectCallback: (callback: () => void) => void;
// 动态注入 WebSocket 连接
injectWebSocket: (ws: WebSocket) => void;
// 创建 Offer供外部调用
createOfferForMedia: () => Promise<boolean>;
}
/**
* WebRTC 核心连接管理 Hook
* 负责基础的 WebRTC 连接管理,包括 WebSocket 连接、PeerConnection 创建和管理
* 初始化时不需要 WebSocket可以通过 injectWebSocket 动态注入
*/
export function useWebRTCConnectionCore(
stateManager: IWebConnectStateManager,
dataChannelManager: WebRTCDataChannelManager,
trackManager: WebRTCTrackManager
): WebRTCConnectionCore {
// WebSocket 连接引用,初始为空
const wsRef = useRef<WebSocket | null>(null);
const isExternalWebSocket = useRef<boolean>(false);
const pcRef = useRef<RTCPeerConnection | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// 当前连接的房间信息
const currentRoom = useRef<{ code: string; role: Role } | null>(null);
// 用于跟踪是否是用户主动断开连接
const isUserDisconnecting = useRef<boolean>(false);
// 断开连接回调
const onDisconnectCallback = useRef<(() => void) | null>(null);
// 清理连接
const cleanup = useCallback((shouldNotifyDisconnect: boolean = false) => {
console.log('[ConnectionCore] 清理连接, 是否发送断开通知:', shouldNotifyDisconnect);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
// 在清理 WebSocket 之前发送断开通知
if (shouldNotifyDisconnect && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({
type: 'disconnection',
payload: { reason: '用户主动断开' }
}));
console.log('[ConnectionCore] 📤 清理时已通知对方断开连接');
} catch (error) {
console.warn('[ConnectionCore] 清理时发送断开通知失败:', error);
}
}
// 如果是外部 WebSocket不关闭连接只是清理引用
// 外部 WebSocket 的生命周期由外部管理
if (!isExternalWebSocket.current && wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
currentRoom.current = null;
isUserDisconnecting.current = false; // 重置主动断开标志
}, []);
// 创建 Offer应该在 Core 层处理信令)
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
try {
console.log('[ConnectionCore] 🎬 开始创建offer当前轨道数量:', pc.getSenders().length);
// 确保连接状态稳定
if (pc.connectionState !== 'connecting' && pc.connectionState !== 'new') {
console.warn('[ConnectionCore] ⚠️ PeerConnection状态异常:', pc.connectionState);
}
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
console.log('[ConnectionCore] 📝 Offer创建成功设置本地描述...');
await pc.setLocalDescription(offer);
console.log('[ConnectionCore] ✅ 本地描述设置完成');
// 等待ICE候选收集完成或超时
await new Promise<void>((resolve) => {
const iceTimeout = setTimeout(() => {
console.log('[ConnectionCore] ⏱️ ICE收集超时继续发送offer');
resolve();
}, 3000); // 减少超时时间到3秒
// 如果ICE收集已经完成立即发送
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
resolve();
} else {
// 创建一个临时的监听器等待ICE收集完成
const originalHandler = pc.onicegatheringstatechange;
pc.onicegatheringstatechange = (event) => {
console.log('[ConnectionCore] 🧊 ICE收集状态变化:', pc.iceGatheringState);
// 调用原始处理器(如果存在)
if (originalHandler) {
originalHandler.call(pc, event);
}
if (pc.iceGatheringState === 'complete') {
clearTimeout(iceTimeout);
// 恢复原始处理器
pc.onicegatheringstatechange = originalHandler;
resolve();
}
};
}
});
// 发送offer
if (ws.readyState === WebSocket.OPEN && pc.localDescription) {
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
console.log('[ConnectionCore] 📤 发送 offer');
}
} catch (error) {
console.error('[ConnectionCore] ❌ 创建 offer 失败:', error);
stateManager.updateState({ error: '创建连接失败', isConnecting: false, canRetry: true });
}
}, [stateManager]);
// 创建 PeerConnection 和相关设置
const createPeerConnection = useCallback((ws: WebSocket, role: 'sender' | 'receiver', isReconnect: boolean = false) => {
console.log('[ConnectionCore] 🔧 创建PeerConnection...', { role, isReconnect });
// 如果已经存在PeerConnection先关闭它
if (pcRef.current) {
console.log('[ConnectionCore] 🔧 关闭已存在的PeerConnection');
pcRef.current.close();
}
// 获取用户配置的ICE服务器
const iceServers = getIceServersConfig();
console.log('[ConnectionCore] 🧊 使用ICE服务器配置:', iceServers);
// 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: iceServers,
iceCandidatePoolSize: 10,
});
pcRef.current = pc;
// 设置轨道接收处理(对于接收方)
// 注意:这个处理器会在 TrackManager.onTrack() 中被业务逻辑覆盖
pc.ontrack = (event) => {
console.log('[ConnectionCore] 🎥 PeerConnection收到轨道:', event.track.kind, event.track.id, '状态:', event.track.readyState);
console.log('[ConnectionCore] 关联的流数量:', event.streams.length);
console.log('[ConnectionCore] ⚠️ 默认轨道处理器 - 业务层应该通过 TrackManager.onTrack() 设置自己的处理器');
};
// PeerConnection 事件处理
pc.onicecandidate = (event) => {
if (event.candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'ice-candidate',
payload: event.candidate
}));
console.log('[ConnectionCore] 📤 发送 ICE 候选:', event.candidate.candidate.substring(0, 50) + '...');
} else if (!event.candidate) {
console.log('[ConnectionCore] 🏁 ICE 收集完成');
}
};
pc.oniceconnectionstatechange = () => {
console.log('[ConnectionCore] 🧊 ICE连接状态变化:', pc.iceConnectionState);
switch (pc.iceConnectionState) {
case 'checking':
console.log('[ConnectionCore] 🔍 正在检查ICE连接...');
break;
case 'connected':
case 'completed':
console.log('[ConnectionCore] ✅ ICE连接成功');
break;
case 'failed':
console.error('[ConnectionCore] ❌ ICE连接失败');
stateManager.updateState({ error: 'ICE连接失败可能是网络防火墙阻止了连接', isConnecting: false, canRetry: true });
break;
case 'disconnected':
console.log('[ConnectionCore] 🔌 ICE连接断开');
break;
case 'closed':
console.log('[ConnectionCore] 🚫 ICE连接已关闭');
break;
}
};
pc.onconnectionstatechange = () => {
console.log('[ConnectionCore] 🔗 WebRTC连接状态变化:', pc.connectionState);
switch (pc.connectionState) {
case 'connecting':
console.log('[ConnectionCore] 🔄 WebRTC正在连接中...');
stateManager.updateState({
isPeerConnected: false,
isConnecting: true,
isConnected: false
});
break;
case 'connected':
console.log('[ConnectionCore] 🎉 WebRTC P2P连接已完全建立可以进行媒体传输');
// 确保所有连接状态都正确更新
stateManager.updateState({
isWebSocketConnected: true,
isConnected: true,
isPeerConnected: true,
isConnecting: false,
error: null,
canRetry: false
});
// 如果是重新连接,触发数据同步
if (isReconnect) {
console.log('[ConnectionCore] 🔄 检测到重新连接,触发数据同步');
// 发送同步请求消息
setTimeout(() => {
const dc = pcRef.current?.createDataChannel('sync-channel');
if (dc && dc.readyState === 'open') {
dc.send(JSON.stringify({
type: 'sync-request',
payload: { timestamp: Date.now() }
}));
console.log('[ConnectionCore] 📤 发送数据同步请求');
dc.close();
}
}, 500); // 等待数据通道完全稳定
}
break;
case 'failed':
console.error('[ConnectionCore] ❌ WebRTC连接失败');
stateManager.updateState({
error: 'WebRTC连接失败请检查网络设置或重试',
isPeerConnected: false,
isConnecting: false,
isConnected: false,
canRetry: true
});
break;
case 'disconnected':
console.log('[ConnectionCore] 🔌 WebRTC连接已断开');
stateManager.updateState({
isPeerConnected: false,
isConnecting: false,
isConnected: false
});
break;
case 'closed':
console.log('[ConnectionCore] 🚫 WebRTC连接已关闭');
stateManager.updateState({
isPeerConnected: false,
isConnecting: false,
isConnected: false
});
break;
}
};
// 创建数据通道
dataChannelManager.createDataChannel(pc, role, isReconnect);
// 立即设置 TrackManager 的 PeerConnection 引用
trackManager.setPeerConnection(pc);
trackManager.setWebSocket(ws);
console.log('[ConnectionCore] ✅ PeerConnection创建完成角色:', role, '是否重新连接:', isReconnect);
console.log('[ConnectionCore] ✅ TrackManager 引用已设置');
return pc;
}, [stateManager, dataChannelManager, trackManager]);
// 连接到房间
const connect = useCallback(async (roomCode: string, role: Role) => {
console.log('[ConnectionCore] 🚀 开始连接到房间:', roomCode, role);
// 如果正在连接中,避免重复连接
const state = stateManager.getState();
if (state.isConnecting) {
console.warn('[ConnectionCore] ⚠️ 正在连接中,跳过重复连接请求');
return;
}
// 检查是否是重新连接(页面关闭后重新打开)
const isReconnect = currentRoom.current?.code === roomCode && currentRoom.current?.role === role;
if (isReconnect) {
console.log('[ConnectionCore] 🔄 检测到重新连接,清理旧连接');
}
// 清理之前的连接
cleanup();
currentRoom.current = { code: roomCode, role };
stateManager.setCurrentRoom({ code: roomCode, role });
stateManager.updateState({ isConnecting: true, error: null });
// 重置主动断开标志
isUserDisconnecting.current = false;
try {
// 保存重新连接状态,供后续使用
const reconnectState = { isReconnect, role };
// 必须使用注入的 WebSocket 连接
if (!wsRef.current) {
throw new Error('WebSocket 连接未注入,请先调用 injectWebSocket 方法');
}
const ws = wsRef.current;
console.log('[ConnectionCore] 使用注入的 WebSocket 连接,状态:', ws.readyState);
// 检查 WebSocket 是否已经连接
if (ws.readyState === WebSocket.OPEN) {
console.log('[ConnectionCore] WebSocket 已连接,房间准备就绪');
stateManager.updateState({
isWebSocketConnected: true,
isConnecting: false, // WebSocket连接成功即表示初始连接完成
isConnected: true // 可以开始后续操作
});
} else if (ws.readyState === WebSocket.CONNECTING) {
// 如果 WebSocket 还在连接中,等待连接成功
console.log('[ConnectionCore] WebSocket 连接中,等待连接完成');
stateManager.updateState({ isConnecting: true, error: null });
// 设置 WebSocket 的事件处理
const originalOnOpen = ws.onopen;
ws.onopen = (event) => {
console.log('[ConnectionCore] ✅ WebSocket 连接已建立,房间准备就绪');
stateManager.updateState({
isWebSocketConnected: true,
isConnecting: false, // WebSocket连接成功即表示初始连接完成
isConnected: true // 可以开始后续操作
});
// 调用原始处理器
if (originalOnOpen) {
originalOnOpen.call(ws, event);
}
};
} else {
throw new Error('WebSocket 连接状态异常: ' + ws.readyState);
}
// 设置 WebSocket 消息处理
if (ws) {
// 如果是外部 WebSocket可能已经有事件处理器我们需要保存它们
const originalOnError = ws.onerror;
const originalOnClose = ws.onclose;
ws.onmessage = async (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
console.log('[ConnectionCore] 📨 收到信令消息:', message.type);
switch (message.type) {
case 'peer-joined':
// 对方加入房间的通知
console.log('[ConnectionCore] 👥 对方已加入房间,角色:', message.payload?.role);
if (role === 'sender' && message.payload?.role === 'receiver') {
console.log('[ConnectionCore] 🚀 接收方已连接发送方开始建立P2P连接');
// 确保WebSocket连接状态正确更新
stateManager.updateState({
isWebSocketConnected: true,
isConnected: true,
isJoinedRoom: true,
});
// 如果是重新连接先清理旧的PeerConnection
if (reconnectState.isReconnect && pcRef.current) {
console.log('[ConnectionCore] 🔄 重新连接清理旧的PeerConnection');
pcRef.current.close();
pcRef.current = null;
}
// 对方加入后创建PeerConnection
const pc = createPeerConnection(ws, role, reconnectState.isReconnect);
// 设置轨道管理器的引用
trackManager.setPeerConnection(pc);
trackManager.setWebSocket(ws);
// 发送方创建offer建立基础P2P连接
try {
console.log('[ConnectionCore] 📡 创建基础P2P连接offer');
await createOffer(pc, ws);
} catch (error) {
console.error('[ConnectionCore] 创建基础P2P连接失败:', error);
}
} else if (role === 'receiver' && message.payload?.role === 'sender') {
console.log('[ConnectionCore] 🚀 发送方已连接接收方准备接收P2P连接');
// 确保WebSocket连接状态正确更新
stateManager.updateState({
isWebSocketConnected: true,
isConnected: true,
isJoinedRoom: true,
});
// 如果是重新连接先清理旧的PeerConnection
if (reconnectState.isReconnect && pcRef.current) {
console.log('[ConnectionCore] 🔄 重新连接清理旧的PeerConnection');
pcRef.current.close();
pcRef.current = null;
}
// 对方加入后立即创建PeerConnection准备接收offer
const pc = createPeerConnection(ws, role, reconnectState.isReconnect);
// 设置轨道管理器的引用
trackManager.setPeerConnection(pc);
trackManager.setWebSocket(ws);
// 等待一小段时间确保PeerConnection完全初始化
setTimeout(() => {
console.log('[ConnectionCore] ✅ 接收方PeerConnection已准备就绪');
}, 100);
}
break;
case 'offer':
console.log('[ConnectionCore] 📬 处理offer...');
// 如果PeerConnection不存在先创建它
let pcOffer = pcRef.current;
if (!pcOffer) {
console.log('[ConnectionCore] 🔧 PeerConnection不存在先创建它');
pcOffer = createPeerConnection(ws, role, reconnectState.isReconnect);
// 设置轨道管理器的引用
trackManager.setPeerConnection(pcOffer);
trackManager.setWebSocket(ws);
// 等待一小段时间确保PeerConnection完全初始化
await new Promise(resolve => setTimeout(resolve, 100));
}
if (pcOffer && pcOffer.signalingState === 'stable') {
await pcOffer.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log('[ConnectionCore] ✅ 设置远程描述完成');
const answer = await pcOffer.createAnswer();
await pcOffer.setLocalDescription(answer);
console.log('[ConnectionCore] ✅ 创建并设置answer完成');
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
console.log('[ConnectionCore] 📤 发送 answer');
} else {
console.warn('[ConnectionCore] ⚠️ PeerConnection状态不是stable或不存在:', pcOffer?.signalingState);
}
break;
case 'answer':
console.log('[ConnectionCore] 📬 处理answer...');
let pcAnswer = pcRef.current;
try {
// 如果PeerConnection不存在先创建它
if (!pcAnswer) {
console.log('[ConnectionCore] 🔧 PeerConnection不存在先创建它');
pcAnswer = createPeerConnection(ws, role, reconnectState.isReconnect);
// 设置轨道管理器的引用
trackManager.setPeerConnection(pcAnswer);
trackManager.setWebSocket(ws);
// 等待一小段时间确保PeerConnection完全初始化
await new Promise(resolve => setTimeout(resolve, 100));
}
if (pcAnswer) {
const signalingState = pcAnswer.signalingState;
console.log('[ConnectionCore] 当前信令状态:', signalingState, '角色:', role);
// 如果是发送方且状态是stable说明已经有媒体轨道应该发送新的offer而不是处理answer
if (role === 'sender' && signalingState === 'stable') {
console.log('[ConnectionCore] 🎬 发送方处于stable状态发送包含媒体轨道的新offer');
try {
await createOffer(pcAnswer, ws);
console.log('[ConnectionCore] ✅ 媒体offer发送完成');
} catch (error) {
console.error('[ConnectionCore] ❌ 发送媒体offer失败:', error);
}
} else if (signalingState === 'have-local-offer') {
// 正常的answer处理
await pcAnswer.setRemoteDescription(new RTCSessionDescription(message.payload));
console.log('[ConnectionCore] ✅ answer 处理完成');
} else {
console.warn('[ConnectionCore] ⚠️ PeerConnection状态异常:', signalingState, '跳过answer处理');
}
}
} catch (error) {
console.error('[ConnectionCore] ❌ 处理answer失败:', error);
if (error instanceof Error && error.message.includes('Failed to set local answer sdp')) {
console.warn('[ConnectionCore] ⚠️ Answer处理失败可能是连接状态变化导致的');
// 清理连接状态,让客户端重新连接
stateManager.updateState({ error: 'WebRTC连接状态异常请重新连接', isPeerConnected: false });
}
}
break;
case 'ice-candidate':
let pcIce = pcRef.current;
if (!pcIce) {
console.log('[ConnectionCore] 🔧 PeerConnection不存在先创建它');
pcIce = createPeerConnection(ws, role, reconnectState.isReconnect);
// 等待一小段时间确保PeerConnection完全初始化
await new Promise(resolve => setTimeout(resolve, 100));
}
if (pcIce && message.payload) {
try {
// 即使远程描述未设置也可以先缓存ICE候选
if (pcIce.remoteDescription) {
await pcIce.addIceCandidate(new RTCIceCandidate(message.payload));
console.log('[ConnectionCore] ✅ 添加 ICE 候选成功');
} else {
console.log('[ConnectionCore] 📝 远程描述未设置缓存ICE候选');
// 可以在这里实现ICE候选缓存机制等远程描述设置后再添加
}
} catch (err) {
console.warn('[ConnectionCore] ⚠️ 添加 ICE 候选失败:', err);
}
} else {
console.warn('[ConnectionCore] ⚠️ ICE候选无效或PeerConnection不存在');
}
break;
case 'error':
const errorMessage = message.error || '信令服务器返回未知错误';
console.error('[ConnectionCore] ❌ 信令服务器错误:', errorMessage);
stateManager.updateState({ error: errorMessage, isConnecting: false, canRetry: true });
break;
case 'disconnection':
console.log('[ConnectionCore] 🔌 对方主动断开连接');
// 对方断开连接的处理
stateManager.updateState({
isPeerConnected: false,
isDataChannelConnected: false,
isConnected: false, // 添加这个状态
isJoinedRoom: false,
error: '对方已离开房间',
canRetry: true
});
// 清理P2P连接但保持WebSocket连接允许重新连接
if (pcRef.current) {
pcRef.current.close();
pcRef.current = null;
}
// 调用断开连接回调,通知上层应用清除数据
if (onDisconnectCallback.current) {
console.log('[ConnectionCore] 📞 调用断开连接回调');
onDisconnectCallback.current();
}
break;
default:
console.warn('[ConnectionCore] ⚠️ 未知消息类型:', message.type);
}
} catch (error) {
console.error('[ConnectionCore] ❌ 处理信令消息失败:', error);
// stateManager.updateState({ error: '信令处理失败: ' + error, isConnecting: false, canRetry: true });
}
};
// 对于外部WebSocket需要设置错误和关闭事件处理器
if (isExternalWebSocket.current) {
ws.onerror = (error: Event) => {
console.error('[ConnectionCore] ❌ WebSocket 错误:', error);
stateManager.updateState({ error: 'WebSocket连接失败', isConnecting: false, canRetry: true });
// 调用原始错误处理器
if (originalOnError) {
originalOnError.call(ws, error);
}
};
ws.onclose = (event: CloseEvent) => {
console.log('[ConnectionCore] 🔌 WebSocket 连接已关闭, 代码:', event.code, '原因:', event.reason);
stateManager.updateState({ isWebSocketConnected: false });
// 检查是否是用户主动断开
if (isUserDisconnecting.current) {
console.log('[ConnectionCore] ✅ 用户主动断开,正常关闭');
// 用户主动断开时不显示错误消息
return;
}
// 只有在非正常关闭且不是用户主动断开时才显示错误
if (event.code !== 1000 && event.code !== 1001) { // 非正常关闭
stateManager.updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '连接意外断开'}`, isConnecting: false, canRetry: true });
}
// 调用原始关闭处理器
if (originalOnClose) {
originalOnClose.call(ws, event);
}
};
}
}
} catch (error) {
console.error('[ConnectionCore] 连接失败:', error);
stateManager.updateState({
error: error instanceof Error ? error.message : '连接失败',
isConnecting: false,
canRetry: true
});
}
}, [stateManager, cleanup, createPeerConnection]);
// 断开连接
const disconnect = useCallback((shouldNotifyDisconnect: boolean = false) => {
console.log('[ConnectionCore] 主动断开连接');
// 设置主动断开标志
isUserDisconnecting.current = true;
// 清理连接并发送断开通知
cleanup(shouldNotifyDisconnect);
// 主动断开时,将状态完全重置为初始状态(没有任何错误或消息)
stateManager.resetToInitial();
console.log('[ConnectionCore] ✅ 连接已断开并清理完成');
}, [cleanup, stateManager]);
// 重试连接
const retry = useCallback(async () => {
const room = currentRoom.current;
if (!room) {
console.warn('[ConnectionCore] 没有当前房间信息,无法重试');
stateManager.updateState({ error: '无法重试连接:缺少房间信息', canRetry: false });
return;
}
console.log('[ConnectionCore] 🔄 重试连接到房间:', room.code, room.role);
// 清理当前连接
cleanup();
// 重新连接
await connect(room.code, room.role);
}, [cleanup, connect, stateManager]);
// 获取 PeerConnection 实例
const getPeerConnection = useCallback(() => {
return pcRef.current;
}, []);
// 获取 WebSocket 实例
const getWebSocket = useCallback(() => {
return wsRef.current;
}, []);
// 获取当前房间信息
const getCurrentRoom = useCallback(() => {
return currentRoom.current;
}, []);
// 设置断开连接回调
const setOnDisconnectCallback = useCallback((callback: () => void) => {
onDisconnectCallback.current = callback;
}, []);
// 动态注入 WebSocket 连接
const injectWebSocket = useCallback((ws: WebSocket) => {
console.log('[ConnectionCore] 注入外部 WebSocket 连接');
wsRef.current = ws;
isExternalWebSocket.current = true;
}, []);
// 供外部调用的创建 Offer 方法
const createOfferForMedia = useCallback(async () => {
const pc = pcRef.current;
const ws = wsRef.current;
if (!pc || !ws) {
console.error('[ConnectionCore] PeerConnection 或 WebSocket 不可用');
return false;
}
try {
await createOffer(pc, ws);
return true;
} catch (error) {
console.error('[ConnectionCore] 创建媒体 offer 失败:', error);
return false;
}
}, [createOffer]);
return {
connect,
disconnect,
retry,
getPeerConnection,
getWebSocket,
getCurrentRoom,
setOnDisconnectCallback,
injectWebSocket,
createOfferForMedia,
};
}

View File

@@ -0,0 +1,384 @@
import { useCallback, useEffect, useRef } from 'react';
import { IWebConnectStateManager } from '../state/useWebConnectStateManager';
import { DataHandler, IRegisterEventHandler, IWebMessage, MessageHandler, Role, WebRTCDataChannelManager } from '../types';
/**
* WebRTC 数据通道管理 Hook
* 负责数据通道的创建和管理,处理数据通道消息的发送和接收
*/
export function useWebRTCDataChannelManager(
stateManager: IWebConnectStateManager
): WebRTCDataChannelManager & IRegisterEventHandler {
const dcRef = useRef<RTCDataChannel | null>(null);
const stateManagerRef = useRef(stateManager);
stateManagerRef.current = stateManager;
// 多通道消息处理器
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
// 创建数据通道
const createDataChannel = useCallback((
pc: RTCPeerConnection,
role: Role,
isReconnect: boolean = false
) => {
console.log('[DataChannelManager] 创建数据通道...', { role, isReconnect });
// 如果已经存在数据通道,先关闭它
if (dcRef.current) {
console.log('[DataChannelManager] 关闭已存在的数据通道');
dcRef.current.close();
dcRef.current = null;
}
// 数据通道处理
if (role === 'sender') {
const dataChannel = pc.createDataChannel('shared-channel', {
ordered: true,
maxRetransmits: 3
});
dcRef.current = dataChannel;
dataChannel.onopen = (event) => {
console.log('[DataChannelManager] 数据通道已打开 (发送方)');
// 确保所有连接状态都正确更新
stateManagerRef.current.updateState({
isDataChannelConnected: true,
isConnected: true,
isPeerConnected: true,
error: null,
isConnecting: false,
canRetry: false,
state: 'open',
stateMsg: "数据通道已打开"
});
// 如果是重新连接,触发数据同步
if (isReconnect) {
console.log('[DataChannelManager] 发送方重新连接,数据通道已打开,准备同步数据');
// 发送同步请求消息
setTimeout(() => {
if (dataChannel.readyState === 'open') {
dataChannel.send(JSON.stringify({
type: 'sync-request',
payload: { timestamp: Date.now() }
}));
console.log('[DataChannelManager] 发送方发送数据同步请求');
}
}, 300); // 等待数据通道完全稳定
}
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[DataChannelManager] 数据通道错误:', error);
// 获取更详细的错误信息
let errorMessage = '数据通道连接失败';
let shouldRetry = false;
// 根据数据通道状态提供更具体的错误信息
switch (dataChannel.readyState) {
case 'connecting':
errorMessage = '数据通道正在连接中,请稍候...';
shouldRetry = true;
break;
case 'closing':
errorMessage = '数据通道正在关闭,连接即将断开';
break;
case 'closed':
errorMessage = '数据通道已关闭P2P连接失败';
shouldRetry = true;
break;
default:
// 检查PeerConnection状态
if (pc) {
switch (pc.connectionState) {
case 'failed':
errorMessage = 'P2P连接失败可能是网络防火墙阻止了连接请尝试切换网络或使用VPN';
shouldRetry = true;
break;
case 'disconnected':
errorMessage = 'P2P连接已断开网络可能不稳定';
shouldRetry = true;
break;
default:
errorMessage = '数据通道连接失败,可能是网络环境受限';
shouldRetry = true;
}
}
}
console.error(`[DataChannelManager] 数据通道详细错误 - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
stateManagerRef.current.updateState({
error: errorMessage,
isConnecting: false,
isPeerConnected: false, // 数据通道出错时P2P连接肯定不可用
isDataChannelConnected: false,
canRetry: shouldRetry // 设置是否可以重试
});
};
} else {
pc.ondatachannel = (event) => {
const dataChannel = event.channel;
dcRef.current = dataChannel;
dataChannel.onopen = (event) => {
console.log('[DataChannelManager] 数据通道已打开 (接收方)');
// 确保所有连接状态都正确更新
stateManagerRef.current.updateState({
isWebSocketConnected: true,
isDataChannelConnected: true,
isConnected: true,
isPeerConnected: true,
error: null,
isConnecting: false,
canRetry: false,
state: 'open'
});
// 如果是重新连接,触发数据同步
if (isReconnect) {
console.log('[DataChannelManager] 接收方重新连接,数据通道已打开,准备同步数据');
// 发送同步请求消息
setTimeout(() => {
if (dataChannel.readyState === 'open') {
dataChannel.send(JSON.stringify({
type: 'sync-request',
payload: { timestamp: Date.now() }
}));
console.log('[DataChannelManager] 接收方发送数据同步请求');
}
}, 300); // 等待数据通道完全稳定
}
};
dataChannel.onmessage = handleDataChannelMessage;
dataChannel.onerror = (error) => {
console.error('[DataChannelManager] 数据通道错误 (接收方):', error);
// 获取更详细的错误信息
let errorMessage = '数据通道连接失败';
let shouldRetry = false;
// 根据数据通道状态提供更具体的错误信息
switch (dataChannel.readyState) {
case 'connecting':
errorMessage = '数据通道正在连接中,请稍候...';
shouldRetry = true;
break;
case 'closing':
errorMessage = '数据通道正在关闭,连接即将断开';
break;
case 'closed':
errorMessage = '数据通道已关闭P2P连接失败';
shouldRetry = true;
break;
default:
// 检查PeerConnection状态
if (pc) {
switch (pc.connectionState) {
case 'failed':
errorMessage = 'P2P连接失败可能是网络防火墙阻止了连接请尝试切换网络或使用VPN';
shouldRetry = true;
break;
case 'disconnected':
errorMessage = 'P2P连接已断开网络可能不稳定';
shouldRetry = true;
break;
default:
errorMessage = '数据通道连接失败,可能是网络环境受限';
shouldRetry = true;
}
}
}
console.error(`[DataChannelManager] 数据通道详细错误 (接收方) - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
stateManagerRef.current.updateState({
error: errorMessage,
isConnecting: false,
isPeerConnected: false, // 数据通道出错时P2P连接肯定不可用
isDataChannelConnected: false,
canRetry: shouldRetry // 设置是否可以重试
});
};
};
}
console.log('[DataChannelManager] 数据通道创建完成,角色:', role, '是否重新连接:', isReconnect);
}, [stateManager]);
// 处理数据通道消息
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
console.log('[DataChannelManager] 收到数据通道消息,类型:', typeof event.data);
console.log('[DataChannelManager] 数据通道当前状态:', messageHandlers.current);
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data) as IWebMessage;
console.log('[DataChannelManager] 收到消息:', message.type, message.channel || 'default');
// 根据通道分发消息
if (message.channel) {
const handler = messageHandlers.current.get(message.channel);
if (handler) {
handler(message);
}
} else {
// 兼容旧版本,广播给所有处理器
messageHandlers.current.forEach(handler => handler(message));
}
} catch (error) {
console.error('[DataChannelManager] 解析消息失败:', error);
}
} else if (event.data instanceof ArrayBuffer) {
console.log('[DataChannelManager] 收到数据:', event.data.byteLength, 'bytes');
// 数据优先发给文件传输处理器
const fileHandler = dataHandlers.current.get('file-transfer');
if (fileHandler) {
fileHandler(event.data);
} else {
// 如果没有文件处理器,发给第一个处理器
const firstHandler = dataHandlers.current.values().next().value;
if (firstHandler) {
firstHandler(event.data);
}
}
}
}, []);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
console.log('[DataChannelManager] 注册消息处理器:', channel);
messageHandlers.current.set(channel, handler);
return () => {
console.log('[DataChannelManager] 取消注册消息处理器:', channel);
messageHandlers.current.delete(channel);
};
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
console.log('[DataChannelManager] 注册数据处理器:', channel);
dataHandlers.current.set(channel, handler);
return () => {
console.log('[DataChannelManager] 取消注册数据处理器:', channel);
dataHandlers.current.delete(channel);
};
}, []);
// 发送消息
const sendMessage = useCallback((message: IWebMessage, channel?: string) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[DataChannelManager] 数据通道未准备就绪');
return false;
}
try {
const messageWithChannel = channel ? { ...message, channel } : message;
dataChannel.send(JSON.stringify(messageWithChannel));
console.log('[DataChannelManager] 发送消息:', message.type, channel || 'default');
return true;
} catch (error) {
console.error('[DataChannelManager] 发送消息失败:', error);
return false;
}
}, []);
// 发送二进制数据
const sendData = useCallback((data: ArrayBuffer) => {
const dataChannel = dcRef.current;
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[DataChannelManager] 数据通道未准备就绪');
return false;
}
try {
dataChannel.send(data);
console.log('[DataChannelManager] 发送数据:', data.byteLength, 'bytes');
return true;
} catch (error) {
console.error('[DataChannelManager] 发送数据失败:', error);
return false;
}
}, []);
// 获取数据通道状态
const getChannelState = useCallback(() => {
return stateManagerRef.current.getState();
}, []);
// 实时更新数据通道状态
useEffect(() => {
const updateChannelState = () => {
const readyState = dcRef.current?.readyState || 'closed';
console.log('[DataChannelManager] 数据通道状态更新:', readyState);
// 更新状态存储中的数据通道状态
stateManagerRef.current.updateState({
state: readyState,
isDataChannelConnected: readyState === 'open'
});
};
// 立即更新一次
updateChannelState();
// 如果数据通道存在,设置状态变化监听
if (dcRef.current) {
const dc = dcRef.current;
const originalOnOpen = dc.onopen;
const originalOnClose = dc.onclose;
const originalOnError = dc.onerror;
dc.onopen = (event) => {
console.log('[DataChannelManager] 数据通道打开事件触发');
updateChannelState();
if (originalOnOpen) originalOnOpen.call(dc, event);
};
dc.onclose = (event) => {
console.log('[DataChannelManager] 数据通道关闭事件触发');
updateChannelState();
if (originalOnClose) originalOnClose.call(dc, event);
};
dc.onerror = (error) => {
console.log('[DataChannelManager] 数据通道错误事件触发');
updateChannelState();
if (originalOnError) originalOnError.call(dc, error);
};
}
// 清理函数
return () => {
if (dcRef.current) {
// 恢复原始事件处理器
// 注意:在实际应用中,可能需要更复杂的事件处理器管理
}
};
}, []);
return {
createDataChannel,
sendMessage,
sendData,
getConnectState: getChannelState,
registerDataHandler,
registerMessageHandler,
handleDataChannelMessage,
};
}

View File

@@ -0,0 +1,190 @@
import { useCallback, useRef } from 'react';
import { IWebConnectStateManager } from '../state/useWebConnectStateManager';
import { WebRTCTrackManager } from '../types';
/**
* WebRTC 媒体轨道管理 Hook
* 负责媒体轨道的添加和移除,处理轨道事件
* 信令相关功能(如 createOffer已移至 ConnectionCore
*/
export function useWebRTCTrackManager(
stateManager: IWebConnectStateManager
): WebRTCTrackManager {
const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const retryInProgressRef = useRef<boolean>(false); // 防止多个重试循环
// 媒体协商:通知 Core 层需要重新创建 Offer
// 这个方法由业务层调用,用于添加媒体轨道后的重新协商
const requestOfferRenegotiation = useCallback(async () => {
const pc = pcRef.current;
const ws = wsRef.current;
if (!pc || !ws) {
console.error('[TrackManager] PeerConnection 或 WebSocket 不可用,无法请求重新协商');
return false;
}
try {
console.log('[TrackManager] 📡 请求重新协商 - 媒体轨道已更新');
// 这里应该通过回调或事件通知 Core 层重新创建 Offer
// 暂时直接调用,但更好的设计是通过事件系统
// 触发重新协商事件(应该由 Core 层监听)
console.log('[TrackManager] ⚠️ 需要 Core 层支持重新协商回调机制');
return true;
} catch (error) {
console.error('[TrackManager] 请求重新协商失败:', error);
return false;
}
}, []);
// 添加媒体轨道
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
const pc = pcRef.current;
if (!pc) {
console.error('[TrackManager] PeerConnection 不可用');
return null;
}
try {
return pc.addTrack(track, stream);
} catch (error) {
console.error('[TrackManager] 添加轨道失败:', error);
return null;
}
}, []);
// 移除媒体轨道
const removeTrack = useCallback((sender: RTCRtpSender) => {
const pc = pcRef.current;
if (!pc) {
console.error('[TrackManager] PeerConnection 不可用');
return;
}
try {
pc.removeTrack(sender);
} catch (error) {
console.error('[TrackManager] 移除轨道失败:', error);
}
}, []);
// 设置轨道处理器
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void) => {
const pc = pcRef.current;
if (!pc) {
console.warn('[TrackManager] PeerConnection 尚未准备就绪将在连接建立后设置onTrack');
// 检查是否已有重试在进行,避免多个重试循环
if (retryInProgressRef.current) {
console.log('[TrackManager] 已有重试进程在运行,跳过重复重试');
return;
}
// 检查WebSocket连接状态只有连接后才尝试设置
const state = stateManager.getState();
if (!state.isWebSocketConnected) {
console.log('[TrackManager] WebSocket未连接等待连接建立...');
return;
}
retryInProgressRef.current = true;
// 延迟设置等待PeerConnection准备就绪
let retryCount = 0;
const maxRetries = 20; // 减少重试次数到20次即2秒
const checkAndSetTrackHandler = () => {
const currentPc = pcRef.current;
if (currentPc) {
console.log('[TrackManager] ✅ PeerConnection 已准备就绪设置onTrack处理器');
currentPc.ontrack = handler;
retryInProgressRef.current = false; // 成功后重置标记
// 如果已经有远程轨道,立即触发处理
const receivers = currentPc.getReceivers();
console.log(`[TrackManager] 📡 当前有 ${receivers.length} 个接收器`);
receivers.forEach(receiver => {
if (receiver.track) {
console.log(`[TrackManager] 🎥 发现现有轨道: ${receiver.track.kind}, ${receiver.track.id}, 状态: ${receiver.track.readyState}`);
}
});
} else {
retryCount++;
if (retryCount < maxRetries) {
// 每5次重试输出一次日志减少日志数量
if (retryCount % 5 === 0) {
console.log(`[TrackManager] ⏳ 等待PeerConnection准备就绪... (尝试: ${retryCount}/${maxRetries})`);
}
setTimeout(checkAndSetTrackHandler, 100);
} else {
console.error('[TrackManager] ❌ PeerConnection 长时间未准备就绪,停止重试');
retryInProgressRef.current = false; // 失败后也要重置标记
}
}
};
checkAndSetTrackHandler();
return;
}
console.log('[TrackManager] ✅ 立即设置onTrack处理器');
pc.ontrack = handler;
// 检查是否已有轨道
const receivers = pc.getReceivers();
console.log(`[TrackManager] 📡 当前有 ${receivers.length} 个接收器`);
receivers.forEach(receiver => {
if (receiver.track) {
console.log(`[TrackManager] 🎥 发现现有轨道: ${receiver.track.kind}, ${receiver.track.id}, 状态: ${receiver.track.readyState}`);
}
});
}, [stateManager]);
// 立即触发重新协商(用于媒体轨道添加后的重新协商)
const triggerRenegotiation = useCallback(async () => {
const pc = pcRef.current;
const ws = wsRef.current;
if (!pc || !ws) {
console.error('[TrackManager] PeerConnection 或 WebSocket 不可用');
return false;
}
try {
console.log('[TrackManager] 📡 触发媒体重新协商');
// 实际的 offer 创建应该由 Core 层处理
// 这里只是一个触发器,通知需要重新协商
return true;
} catch (error) {
console.error('[TrackManager] 触发重新协商失败:', error);
return false;
}
}, []);
// 设置 PeerConnection 引用
const setPeerConnection = useCallback((pc: RTCPeerConnection | null) => {
pcRef.current = pc;
// 当PeerConnection设置时重置重试标记
if (pc) {
retryInProgressRef.current = false;
}
}, []);
// 设置 WebSocket 引用
const setWebSocket = useCallback((ws: WebSocket | null) => {
wsRef.current = ws;
}, []);
return {
addTrack,
removeTrack,
onTrack,
requestOfferRenegotiation,
triggerRenegotiation,
// 内部方法,供核心连接管理器调用
setPeerConnection,
setWebSocket,
};
}

View File

@@ -0,0 +1,2 @@
// WebSocket 连接相关导出
export { useWebSocketConnection } from './useWebSocketConnection';

View File

@@ -0,0 +1,450 @@
import { useCallback, useEffect, useRef } from 'react';
import { useWebConnectStateManager } from '../state/useWebConnectStateManager';
import { WebConnectState } from '../state/webConnectStore';
import { ConnectType, DataHandler, IWebConnection, IWebMessage, MessageHandler, Role } from '../types';
/**
* WebSocket 连接管理器
* 实现 IWebConnection 接口,提供基于 WebSocket 的数据传输
* 支持注入外部 WebSocket 连接
*/
export function useWebSocketConnection(): IWebConnection & { injectWebSocket: (ws: WebSocket) => void } {
const wsRef = useRef<WebSocket | null>(null);
const currentRoomRef = useRef<{ code: string; role: Role } | null>(null);
// 事件处理器存储
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
// 断开连接回调
const onDisconnectCallback = useRef<(() => void) | null>(null);
// 全局状态管理器
const stateManager = useWebConnectStateManager();
// 创建稳定的状态管理器引用,避免无限循环
const stateManagerRef = useRef(stateManager);
stateManagerRef.current = stateManager;
// 缓存上次的状态,用于比较是否真正改变
const lastStateRef = useRef<Partial<WebConnectState>>({});
// 智能状态更新 - 只在状态真正改变时才更新,使用稳定引用
const updateState = useCallback((updates: Partial<WebConnectState>) => {
// 检查状态是否真正改变
const hasChanged = Object.keys(updates).some(key => {
const typedKey = key as keyof WebConnectState;
return lastStateRef.current[typedKey] !== updates[typedKey];
});
if (hasChanged) {
console.log('[WebSocket] 状态更新:', updates);
lastStateRef.current = { ...lastStateRef.current, ...updates };
stateManagerRef.current.updateState(updates);
} else {
console.log('[WebSocket] 状态未改变,跳过更新:', updates);
}
}, []); // 空依赖数组,使用 ref 访问最新的 stateManager
// 连接到房间
const connect = useCallback(async (roomCode: string, role: Role) => {
// 检查是否已经注入了 WebSocket
if (!wsRef.current) {
throw new Error('[WebSocket] 尚未注入 WebSocket 连接,请先调用 injectWebSocket');
}
const ws = wsRef.current;
// 检查 WebSocket 状态
if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
throw new Error('[WebSocket] 注入的 WebSocket 连接已关闭');
}
updateState({ isConnecting: true, error: null, canRetry: false });
currentRoomRef.current = { code: roomCode, role };
try {
console.log('[WebSocket] 使用注入的 WebSocket 连接到房间:', roomCode, '角色:', role);
// 如果 WebSocket 已经连接,直接更新状态
if (ws.readyState === WebSocket.OPEN) {
console.log('[WebSocket] WebSocket 已连接,直接设置为已连接状态');
updateState({
isConnected: true,
isConnecting: false,
isWebSocketConnected: true,
isPeerConnected: true, // 欺骗 UI让 WebRTC 相关功能正常工作
isDataChannelConnected: true, // 欺骗 UIWebSocket 也能传输数据
isMediaStreamConnected: true, // 欺骗 UI保证所有功能可用
state: 'open', // RTCDataChannelState.open
error: null,
canRetry: false
});
} else if (ws.readyState === WebSocket.CONNECTING) {
console.log('[WebSocket] WebSocket 正在连接中,等待连接完成');
// WebSocket 正在连接中,等待 onopen 事件
} else {
throw new Error('[WebSocket] WebSocket 状态异常: ' + ws.readyState);
}
} catch (error) {
console.error('[WebSocket] 连接异常:', error);
updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false, // 重置所有 WebRTC 相关状态
isDataChannelConnected: false,
isMediaStreamConnected: false,
state: 'closed', // RTCDataChannelState.closed
error: error instanceof Error ? error.message : '无法使用注入的 WebSocket 连接',
canRetry: true
});
throw error;
}
}, [updateState]);
// 处理收到的消息
const handleMessage = useCallback(async (event: MessageEvent) => {
try {
console.log('[WebSocket] 收到消息事件:', typeof event.data, event.data.constructor?.name,
event.data instanceof ArrayBuffer ? `ArrayBuffer ${event.data.byteLength} bytes` :
event.data instanceof Blob ? `Blob ${event.data.size} bytes` : 'JSON');
// 处理二进制数据 - 支持 ArrayBuffer 和 Blob
if (event.data instanceof ArrayBuffer) {
// 直接的 ArrayBuffer 数据
console.log('[WebSocket] 收到 ArrayBuffer 数据:', event.data.byteLength, 'bytes');
// 优先发给文件传输处理器
const fileHandler = dataHandlers.current.get('file-transfer');
if (fileHandler) {
fileHandler(event.data);
} else {
// 发给第一个处理器
const firstHandler = dataHandlers.current.values().next().value;
if (firstHandler) {
firstHandler(event.data);
}
}
} else if (event.data instanceof Blob) {
// Blob 数据,需要转换为 ArrayBuffer
console.log('[WebSocket] 收到 Blob 数据:', event.data.size, 'bytes正在转换为 ArrayBuffer');
try {
const arrayBuffer = await event.data.arrayBuffer();
console.log('[WebSocket] Blob 转换完成ArrayBuffer 大小:', arrayBuffer.byteLength, 'bytes');
// 优先发给文件传输处理器
const fileHandler = dataHandlers.current.get('file-transfer');
if (fileHandler) {
fileHandler(arrayBuffer);
} else {
// 发给第一个处理器
const firstHandler = dataHandlers.current.values().next().value;
if (firstHandler) {
firstHandler(arrayBuffer);
}
}
} catch (blobError) {
console.error('[WebSocket] Blob 转换为 ArrayBuffer 失败:', blobError);
}
} else if (typeof event.data === 'string') {
// JSON 消息
const message = JSON.parse(event.data) as IWebMessage;
// 特殊处理 disconnection 消息 - 与 WebRTC 保持一致
if (message.type === 'disconnection') {
console.log('[WebSocket] 🔌 对方主动断开连接');
// 更新连接状态
updateState({
isPeerConnected: false,
isConnected: false,
isDataChannelConnected: false,
error: '对方已离开房间',
stateMsg: null,
canRetry: true
});
// 调用断开连接回调,通知上层应用清除数据
if (onDisconnectCallback.current) {
console.log('[WebSocket] 📞 调用断开连接回调');
onDisconnectCallback.current();
}
}
if (message.type === 'peer-joined') {
console.log('[WebSocket] 🎉 对方加入房间')
updateState({
isPeerConnected: true,
isConnected: true,
isWebSocketConnected: true,
currentConnectType: 'websocket',
error: null,
stateMsg: '对方已经加入房间',
canRetry: true
});
}
// 根据通道分发消息
if (message.channel) {
const handler = messageHandlers.current.get(message.channel);
if (handler) {
handler(message);
}
} else {
// 广播给所有处理器
messageHandlers.current.forEach(handler => handler(message));
}
} else {
console.warn('[WebSocket] 收到未知数据类型:', typeof event.data, event.data.constructor?.name, event.data);
}
} catch (error) {
console.error('[WebSocket] 处理消息失败:', error);
}
}, []);
// 断开连接
const disconnect = useCallback(() => {
if (wsRef.current) {
console.log('[WebSocket] 主动断开连接');
wsRef.current.close(1000, '用户主动断开');
wsRef.current = null;
}
currentRoomRef.current = null;
updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false, // 重置所有 WebRTC 相关状态
isDataChannelConnected: false,
isMediaStreamConnected: false,
state: 'closed', // RTCDataChannelState.closed
error: null,
canRetry: false
});
}, [updateState]);
// 重试连接
const retry = useCallback(async () => {
if (currentRoomRef.current) {
console.log('[WebSocket] 重试连接');
await connect(currentRoomRef.current.code, currentRoomRef.current.role);
}
}, [connect]);
// 发送消息
const sendMessage = useCallback((message: IWebMessage, channel?: string) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.error('[WebSocket] 连接未就绪,无法发送消息');
return false;
}
try {
const messageWithChannel = channel ? { ...message, channel } : message;
ws.send(JSON.stringify(messageWithChannel));
console.log('[WebSocket] 发送消息:', message.type, channel || 'default');
return true;
} catch (error) {
console.error('[WebSocket] 发送消息失败:', error);
return false;
}
}, []);
// 发送二进制数据
const sendData = useCallback((data: ArrayBuffer) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.error('[WebSocket] 连接未就绪,无法发送数据');
return false;
}
try {
ws.send(data);
return true;
} catch (error) {
console.error('[WebSocket] 发送数据失败:', error);
return false;
}
}, []);
// 注册消息处理器
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
console.log('[WebSocket] 注册消息处理器:', channel);
messageHandlers.current.set(channel, handler);
return () => {
console.log('[WebSocket] 取消注册消息处理器:', channel);
messageHandlers.current.delete(channel);
};
}, []);
// 注册数据处理器
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
console.log('[WebSocket] 注册数据处理器:', channel);
dataHandlers.current.set(channel, handler);
return () => {
console.log('[WebSocket] 取消注册数据处理器:', channel);
dataHandlers.current.delete(channel);
};
}, []);
// 获取连接状态
const getConnectState = useCallback((): WebConnectState => {
return { ...stateManagerRef.current.getState() };
}, []);
// 检查是否连接到指定房间
const isConnectedToRoom = useCallback((roomCode: string, role: Role) => {
return stateManagerRef.current.isConnectedToRoom(roomCode, role);
}, []);
// 媒体轨道方法WebSocket 不支持,返回 null
const addTrack = useCallback(() => {
console.warn('[WebSocket] WebSocket 不支持媒体轨道');
return null;
}, []);
const removeTrack = useCallback(() => {
console.warn('[WebSocket] WebSocket 不支持媒体轨道');
}, []);
const onTrack = useCallback(() => {
console.warn('[WebSocket] WebSocket 不支持媒体轨道');
}, []);
const getPeerConnection = useCallback(() => {
console.warn('[WebSocket] WebSocket 不支持 PeerConnection');
return null;
}, []);
const createOfferNow = useCallback(async () => {
console.warn('[WebSocket] WebSocket 不支持创建 Offer');
return false;
}, []);
// 注入外部 WebSocket 连接
const injectWebSocket = useCallback((ws: WebSocket) => {
console.log('[WebSocket] 注入外部 WebSocket 连接');
// 如果已有连接,先断开
if (wsRef.current) {
wsRef.current.close();
}
wsRef.current = ws;
// 设置事件处理器
ws.onopen = () => {
console.log('[WebSocket] 注入的 WebSocket 连接成功');
updateState({
currentConnectType: 'websocket',
isConnected: true,
isConnecting: false,
isWebSocketConnected: true,
isPeerConnected: true, // 欺骗 UI让 WebRTC 相关功能正常工作
isDataChannelConnected: true, // 欺骗 UIWebSocket 也能传输数据
isMediaStreamConnected: true, // 欺骗 UI保证所有功能可用
state: 'open', // RTCDataChannelState.open
error: null,
canRetry: false,
});
};
ws.onmessage = (event) => {
handleMessage(event);
};
ws.onerror = (error) => {
console.error('[WebSocket] 注入的 WebSocket 连接错误:', error);
updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false, // 重置所有 WebRTC 相关状态
isDataChannelConnected: false,
isMediaStreamConnected: false,
state: 'closed', // RTCDataChannelState.closed
error: 'WebSocket 连接失败',
canRetry: true
});
};
ws.onclose = (event) => {
console.log('[WebSocket] 注入的 WebSocket 连接关闭:', event.code, event.reason);
updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false, // 重置所有 WebRTC 相关状态
isDataChannelConnected: false,
isMediaStreamConnected: false,
state: 'closed', // RTCDataChannelState.closed
error: event.wasClean ? null : 'WebSocket 连接意外断开',
canRetry: !event.wasClean
});
// 调用断开连接回调
if (onDisconnectCallback.current) {
console.log('[WebSocket] 调用断开连接回调');
onDisconnectCallback.current();
}
};
// 如果 WebSocket 已经连接,立即更新状态
if (ws.readyState === WebSocket.OPEN) {
console.log('[WebSocket] 注入的 WebSocket 已连接,立即更新状态');
updateState({
isConnected: true,
isConnecting: false,
isWebSocketConnected: true,
isPeerConnected: true, // 欺骗 UI让 WebRTC 相关功能正常工作
isDataChannelConnected: true, // 欺骗 UIWebSocket 也能传输数据
isMediaStreamConnected: true, // 欺骗 UI保证所有功能可用
state: 'open', // RTCDataChannelState.open
error: null,
canRetry: false
});
}
}, [handleMessage, updateState]);
// 设置断开连接回调
const setOnDisconnectCallback = useCallback((callback: () => void) => {
onDisconnectCallback.current = callback;
}, []);
// 清理连接
useEffect(() => {
return () => {
// 清理时直接关闭 WebSocket不调用 disconnect 避免状态更新循环
if (wsRef.current) {
console.log('[WebSocket] 组件卸载,清理 WebSocket 连接');
wsRef.current.close(1000, '组件卸载');
wsRef.current = null;
}
currentRoomRef.current = null;
};
}, []); // 空依赖数组,只在组件挂载和卸载时执行
return {
connectType: 'websocket' as ConnectType,
connect,
disconnect,
retry,
sendMessage,
sendData,
registerMessageHandler,
registerDataHandler,
getConnectState,
isConnectedToRoom,
currentRoom: currentRoomRef.current,
addTrack,
removeTrack,
onTrack,
getPeerConnection,
createOfferNow,
setOnDisconnectCallback,
injectWebSocket,
};
}

View File

@@ -1,22 +1,24 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useSharedWebRTCManager } from '../connection/useSharedWebRTCManager';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useConnectManager } from '../connection';
interface DesktopShareState {
isSharing: boolean;
isViewing: boolean;
connectionCode: string;
remoteStream: MediaStream | null;
localStream: MediaStream | null; // 添加到状态中以触发重新渲染
error: string | null;
isWaitingForPeer: boolean; // 新增:是否等待对方连接
}
export function useDesktopShareBusiness() {
const webRTC = useSharedWebRTCManager();
const webRTC = useConnectManager();
const [state, setState] = useState<DesktopShareState>({
isSharing: false,
isViewing: false,
connectionCode: '',
remoteStream: null,
localStream: null,
error: null,
isWaitingForPeer: false,
});
@@ -32,33 +34,74 @@ export function useDesktopShareBusiness() {
// 处理远程流
const handleRemoteStream = useCallback((stream: MediaStream) => {
console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道');
updateState({ remoteStream: stream });
setState(prev => ({ ...prev, remoteStream: stream }));
// 如果有视频元素引用,设置流
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = stream;
}
}, [updateState]);
}, []); // 移除updateState依赖直接使用setState
// 设置远程轨道处理器(始终监听)
useEffect(() => {
console.log('[DesktopShare] 🎧 设置远程轨道处理器');
webRTC.onTrack((event: RTCTrackEvent) => {
console.log('[DesktopShare] 🎥 收到远程轨道:', event.track.kind, event.track.id);
const trackHandler = (event: RTCTrackEvent) => {
console.log('[DesktopShare] 🎥 收到远程轨道:', event.track.kind, event.track.id, '状态:', event.track.readyState);
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);
console.log('[DesktopShare] 远程轨道:', track.kind, track.id, '启用:', track.enabled, '状态:', track.readyState);
});
handleRemoteStream(remoteStream);
// 确保轨道已启用
remoteStream.getTracks().forEach(track => {
if (!track.enabled) {
console.log('[DesktopShare] 🔓 启用远程轨道:', track.id);
track.enabled = true;
}
});
// 直接使用setState而不是handleRemoteStream避免依赖问题
setState(prev => ({ ...prev, remoteStream }));
// 如果有视频元素引用,设置流
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = remoteStream;
}
} else {
console.warn('[DesktopShare] ⚠️ 收到轨道但没有关联的流');
// 尝试从轨道创建流
try {
const newStream = new MediaStream([event.track]);
console.log('[DesktopShare] 🔄 从轨道创建新流:', newStream.id);
// 确保轨道已启用
newStream.getTracks().forEach(track => {
if (!track.enabled) {
console.log('[DesktopShare] 🔓 启用新流中的轨道:', track.id);
track.enabled = true;
}
});
// 直接使用setState
setState(prev => ({ ...prev, remoteStream: newStream }));
// 如果有视频元素引用,设置流
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = newStream;
}
} catch (error) {
console.error('[DesktopShare] ❌ 从轨道创建流失败:', error);
}
}
});
}, [webRTC, handleRemoteStream]);
};
webRTC.onTrack(trackHandler);
}, [webRTC]); // 只依赖webRTC移除handleRemoteStream依赖
// 获取桌面共享流
const getDesktopStream = useCallback(async (): Promise<MediaStream> => {
@@ -86,14 +129,27 @@ export function useDesktopShareBusiness() {
// 设置视频轨道发送
const setupVideoSending = useCallback(async (stream: MediaStream) => {
console.log('[DesktopShare] 🎬 开始设置视频轨道发送...');
// 检查P2P连接状态
if (!webRTC.getConnectState().isPeerConnected) {
console.warn('[DesktopShare] ⚠️ P2P连接尚未完全建立等待连接稳定...');
// 等待连接稳定
await new Promise(resolve => setTimeout(resolve, 1000));
// 再次检查
if (!webRTC.getConnectState().isPeerConnected) {
console.error('[DesktopShare] ❌ P2P连接仍未建立无法开始媒体传输');
throw new Error('P2P连接尚未建立');
}
}
// 移除之前的轨道(如果存在)
if (currentSenderRef.current) {
console.log('[DesktopShare] 🗑️ 移除之前的视频轨道');
webRTC.removeTrack(currentSenderRef.current);
currentSenderRef.current = null;
}
// 添加新的视频轨道到PeerConnection
const videoTrack = stream.getVideoTracks()[0];
const audioTrack = stream.getAudioTracks()[0];
@@ -130,18 +186,39 @@ export function useDesktopShareBusiness() {
// 轨道添加完成,现在需要重新协商以包含媒体轨道
console.log('[DesktopShare] ✅ 桌面共享轨道添加完成,开始重新协商');
// 检查P2P连接是否已建立
if (!webRTC.isPeerConnected) {
console.error('[DesktopShare] ❌ P2P连接尚未建立无法开始媒体传输');
throw new Error('P2P连接尚未建立');
// 获取PeerConnection实例以便调试
const pc = webRTC.getPeerConnection();
if (pc) {
console.log('[DesktopShare] 🔍 当前连接状态:', {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
signalingState: pc.signalingState,
senders: pc.getSenders().length
});
}
// 等待一小段时间确保轨道完全添加
await new Promise(resolve => setTimeout(resolve, 500));
// 创建新的offer包含媒体轨道
console.log('[DesktopShare] 📨 创建包含媒体轨道的新offer进行重新协商');
const success = await webRTC.createOfferNow();
if (success) {
console.log('[DesktopShare] ✅ 媒体轨道重新协商成功');
// 等待重新协商完成
console.log('[DesktopShare] ⏳ 等待重新协商完成...');
await new Promise(resolve => setTimeout(resolve, 2000));
// 检查连接状态
if (pc) {
console.log('[DesktopShare] 🔍 重新协商后连接状态:', {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
signalingState: pc.signalingState
});
}
} else {
console.error('[DesktopShare] ❌ 媒体轨道重新协商失败');
throw new Error('媒体轨道重新协商失败');
@@ -173,7 +250,7 @@ export function useDesktopShareBusiness() {
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '创建房间失败');
}
@@ -181,10 +258,10 @@ export function useDesktopShareBusiness() {
return data.code;
}, []);
// 创建房间(只建立连接,等待对方加入)
const createRoom = useCallback(async (): Promise<string> => {
// 创建房间并立即开始桌面共享
const createRoomAndStartSharing = useCallback(async (): Promise<string> => {
try {
updateState({ error: null, isWaitingForPeer: false });
setState(prev => ({ ...prev, error: null, isWaitingForPeer: false }));
// 从后端获取房间代码
const roomCode = await createRoomFromBackend();
@@ -195,99 +272,170 @@ export function useDesktopShareBusiness() {
await webRTC.connect(roomCode, 'sender');
console.log('[DesktopShare] ✅ WebSocket连接已建立');
updateState({
setState(prev => ({
...prev,
connectionCode: roomCode,
}));
// 立即开始桌面共享不等待P2P连接
console.log('[DesktopShare] 📺 正在请求桌面共享权限...');
const stream = await getDesktopStream();
// 停止之前的流(如果有)
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
}
localStreamRef.current = stream;
console.log('[DesktopShare] ✅ 桌面流获取成功流ID:', stream.id, '轨道数:', stream.getTracks().length);
// 确保状态更新能正确触发重新渲染
setState(prev => ({
...prev,
isSharing: true,
isWaitingForPeer: true, // 等待观看者加入
localStream: stream, // 更新状态以触发组件重新渲染
}));
// 再次确认状态已更新(用于调试)
console.log('[DesktopShare] 🎉 桌面共享已开始,状态已更新,等待观看者加入');
return roomCode;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
console.error('[DesktopShare] ❌ 创建房间失败:', error);
setState(prev => ({ ...prev, error: errorMessage, connectionCode: '', isWaitingForPeer: false, isSharing: false }));
throw error;
}
}, [webRTC, createRoomFromBackend, getDesktopStream]); // 移除updateState依赖
// 创建房间(保留原有方法以兼容性)
const createRoom = useCallback(async (): Promise<string> => {
try {
setState(prev => ({ ...prev, error: null, isWaitingForPeer: false }));
// 从后端获取房间代码
const roomCode = await createRoomFromBackend();
console.log('[DesktopShare] 🚀 创建桌面共享房间,代码:', roomCode);
// 建立WebRTC连接作为发送方
console.log('[DesktopShare] 📡 正在建立WebRTC连接...');
await webRTC.connect(roomCode, 'sender');
console.log('[DesktopShare] ✅ WebSocket连接已建立');
setState(prev => ({
...prev,
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 });
setState(prev => ({ ...prev, error: errorMessage, connectionCode: '', isWaitingForPeer: false }));
throw error;
}
}, [webRTC, createRoomFromBackend, updateState]);
}, [webRTC, createRoomFromBackend]); // 移除updateState依赖
// 开始桌面共享(在接收方加入后
// 开始桌面共享(支持有或无P2P连接状态
const startSharing = useCallback(async (): Promise<void> => {
try {
// 检查WebSocket连接状态
if (!webRTC.isWebSocketConnected) {
throw new Error('WebSocket连接未建立请先创建房间');
}
updateState({ error: null });
setState(prev => ({ ...prev, error: null }));
console.log('[DesktopShare] 📺 正在请求桌面共享权限...');
// 获取桌面流
const stream = await getDesktopStream();
// 停止之前的流(如果有)
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
}
localStreamRef.current = stream;
console.log('[DesktopShare] ✅ 桌面流获取成功');
console.log('[DesktopShare] ✅ 桌面流获取成功流ID:', stream.id, '轨道数:', stream.getTracks().length);
// 设置视频发送这会添加轨道并创建offer启动P2P连接
console.log('[DesktopShare] 📤 正在设置视频轨道推送并建立P2P连接...');
await setupVideoSending(stream);
console.log('[DesktopShare] ✅ 视频轨道推送设置完成');
updateState({
isSharing: true,
isWaitingForPeer: false,
});
// 如果P2P连接已建立立即设置视频发送
if (webRTC.getConnectState().isPeerConnected) {
await setupVideoSending(stream);
console.log('[DesktopShare] ✅ 桌面共享开始完成P2P已连接');
setState(prev => ({
...prev,
isSharing: true,
isWaitingForPeer: false,
localStream: stream,
}));
} else {
// P2P连接未建立等待观看者加入
console.log('[DesktopShare] 📱 桌面流已准备等待观看者加入建立P2P连接');
setState(prev => ({
...prev,
isSharing: true,
isWaitingForPeer: true,
localStream: stream,
}));
}
console.log('[DesktopShare] 🎉 桌面共享已开始');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
console.error('[DesktopShare] ❌ 开始共享失败:', error);
updateState({ error: errorMessage, isSharing: false });
setState(prev => ({
...prev,
error: errorMessage,
isSharing: false,
localStream: null,
}));
// 清理资源
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach(track => track.stop());
localStreamRef.current = null;
}
throw error;
}
}, [webRTC, getDesktopStream, setupVideoSending, updateState]);
}, [webRTC, getDesktopStream]); // 移除setupVideoSendingupdateState依赖
// 切换桌面共享(重新选择屏幕)
const switchDesktop = useCallback(async (): Promise<void> => {
try {
if (!webRTC.isPeerConnected) {
throw new Error('P2P连接未建立');
}
if (!state.isSharing) {
throw new Error('当前未在共享桌面');
}
updateState({ error: null });
setState(prev => ({ ...prev, 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] ✅ 桌面切换完成');
localStreamRef.current = newStream;
console.log('[DesktopShare] ✅ 新桌面流获取成功流ID:', newStream.id, '轨道数:', newStream.getTracks().length);
// 更新状态中的本地流
setState(prev => ({ ...prev, localStream: newStream }));
// 如果有P2P连接设置新的视频发送
if (webRTC.getConnectState().isPeerConnected) {
await setupVideoSending(newStream);
console.log('[DesktopShare] ✅ 桌面切换完成(已推流给观看者)');
} else {
console.log('[DesktopShare] ✅ 桌面切换完成(等待观看者加入)');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
console.error('[DesktopShare] ❌ 切换桌面失败:', error);
updateState({ error: errorMessage });
setState(prev => ({ ...prev, error: errorMessage }));
throw error;
}
}, [webRTC, state.isSharing, getDesktopStream, setupVideoSending, updateState]);
}, [webRTC, state.isSharing, getDesktopStream]); // 移除setupVideoSendingupdateState依赖
// 停止桌面共享
const stopSharing = useCallback(async (): Promise<void> => {
@@ -312,25 +460,123 @@ export function useDesktopShareBusiness() {
// 断开WebRTC连接
webRTC.disconnect();
updateState({
setState(prev => ({
...prev,
isSharing: false,
connectionCode: '',
error: null,
isWaitingForPeer: false,
});
localStream: null,
}));
console.log('[DesktopShare] 桌面共享已停止');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
console.error('[DesktopShare] 停止共享失败:', error);
setState(prev => ({ ...prev, error: errorMessage }));
}
}, [webRTC]); // 移除updateState依赖直接使用setState
// 重置桌面共享到初始状态(让用户重新选择桌面)
const resetSharing = 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;
}
// 保留WebSocket连接和房间代码但重置共享状态
setState(prev => ({
...prev,
isSharing: false,
error: null,
isWaitingForPeer: false,
localStream: null,
}));
console.log('[DesktopShare] 桌面共享已重置到初始状态');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '重置桌面共享失败';
console.error('[DesktopShare] 重置共享失败:', error);
setState(prev => ({ ...prev, error: errorMessage }));
}
}, [webRTC]); // 移除updateState依赖
// 处理P2P连接断开但保持桌面共享状态用于接收方离开房间的情况
const handlePeerDisconnect = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] P2P连接断开但保持桌面共享状态以便新用户加入');
// 移除当前的发送器清理P2P连接相关资源
if (currentSenderRef.current) {
webRTC.removeTrack(currentSenderRef.current);
currentSenderRef.current = null;
}
// 保持本地流和共享状态,只设置为等待新的对等方
setState(prev => ({
...prev,
isWaitingForPeer: true,
error: null,
}));
console.log('[DesktopShare] 已清理P2P连接资源等待新用户加入');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '处理P2P断开失败';
console.error('[DesktopShare] 处理P2P断开失败:', error);
setState(prev => ({ ...prev, error: errorMessage }));
}
}, [webRTC]); // 移除updateState依赖
// 重新建立P2P连接并推流用于新用户加入房间的情况
const resumeSharing = useCallback(async (): Promise<void> => {
try {
console.log('[DesktopShare] 新用户加入重新建立P2P连接并推流');
// 检查是否还在共享状态且有本地流
if (!state.isSharing || !localStreamRef.current) {
console.log('[DesktopShare] 当前没有在共享或没有本地流,无法恢复推流');
return;
}
// 检查P2P连接状态
if (!webRTC.getConnectState().isPeerConnected) {
console.log('[DesktopShare] P2P连接未建立等待连接完成');
return;
}
// 重新设置视频发送
await setupVideoSending(localStreamRef.current);
updateState({
isWaitingForPeer: false,
error: null,
});
console.log('[DesktopShare] ✅ P2P连接已恢复桌面共享流已重新建立');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '恢复桌面共享失败';
console.error('[DesktopShare] 恢复桌面共享失败:', error);
updateState({ error: errorMessage });
}
}, [webRTC, updateState]);
}, [webRTC, state.isSharing]); // 移除setupVideoSending和updateState依赖
// 加入桌面共享观看
const joinSharing = useCallback(async (code: string): Promise<void> => {
try {
updateState({ error: null });
setState(prev => ({ ...prev, error: null }));
console.log('[DesktopShare] 🔍 正在加入桌面共享观看:', code);
// 连接WebRTC
@@ -342,15 +588,33 @@ export function useDesktopShareBusiness() {
console.log('[DesktopShare] ⏳ 等待连接稳定...');
await new Promise(resolve => setTimeout(resolve, 1000));
updateState({ isViewing: true });
// 检查连接状态
const pc = webRTC.getPeerConnection();
if (pc) {
console.log('[DesktopShare] 🔍 连接状态:', {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
signalingState: pc.signalingState
});
}
setState(prev => ({ ...prev, isViewing: true }));
console.log('[DesktopShare] 👁️ 已进入桌面共享观看模式,等待接收流...');
// 设置一个超时检查,如果长时间没有收到流,输出警告
setTimeout(() => {
if (!state.remoteStream) {
console.warn('[DesktopShare] ⚠️ 长时间未收到远程流,可能存在连接问题');
// 可以在这里添加一些恢复逻辑,比如尝试重新连接
}
}, 10000); // 10秒后检查
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '加入桌面共享失败';
console.error('[DesktopShare] ❌ 加入观看失败:', error);
updateState({ error: errorMessage, isViewing: false });
setState(prev => ({ ...prev, error: errorMessage, isViewing: false }));
throw error;
}
}, [webRTC, updateState]);
}, [webRTC, state.remoteStream]); // 移除updateState依赖
// 停止观看桌面共享
const stopViewing = useCallback(async (): Promise<void> => {
@@ -360,19 +624,20 @@ export function useDesktopShareBusiness() {
// 断开WebRTC连接
webRTC.disconnect();
updateState({
setState(prev => ({
...prev,
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 });
setState(prev => ({ ...prev, error: errorMessage }));
}
}, [webRTC, updateState]);
}, [webRTC]); // 移除updateState依赖
// 设置远程视频元素引用
const setRemoteVideoRef = useCallback((videoElement: HTMLVideoElement | null) => {
@@ -382,6 +647,55 @@ export function useDesktopShareBusiness() {
}
}, [state.remoteStream]);
// 监听P2P连接状态变化自动处理重新连接
const prevPeerConnectedForResumeRef = useRef<boolean>(false);
useEffect(() => {
const isPeerConnected = webRTC.getConnectState().isPeerConnected;
const wasPreviouslyDisconnected = !prevPeerConnectedForResumeRef.current;
// 更新ref
prevPeerConnectedForResumeRef.current = isPeerConnected;
// 当P2P连接从断开变为连接且正在等待对方时自动恢复推流
if (isPeerConnected &&
wasPreviouslyDisconnected &&
state.isWaitingForPeer &&
state.isSharing) {
console.log('[DesktopShare] 🔄 P2P连接已建立自动恢复桌面共享推流');
// 调用resumeSharing但不依赖它
const handleResume = async () => {
try {
console.log('[DesktopShare] 新用户加入重新建立P2P连接并推流');
// 检查是否还在共享状态且有本地流
if (!state.isSharing || !localStreamRef.current) {
console.log('[DesktopShare] 当前没有在共享或没有本地流,无法恢复推流');
return;
}
// 重新设置视频发送
await setupVideoSending(localStreamRef.current);
setState(prev => ({
...prev,
isWaitingForPeer: false,
error: null,
}));
console.log('[DesktopShare] ✅ P2P连接已恢复桌面共享流已重新建立');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '恢复桌面共享失败';
console.error('[DesktopShare] 恢复桌面共享失败:', error);
setState(prev => ({ ...prev, error: errorMessage }));
}
};
handleResume();
}
}, [webRTC.getConnectState().isPeerConnected, state.isWaitingForPeer, state.isSharing]); // 移除resumeSharing依赖
// 清理资源
useEffect(() => {
return () => {
@@ -397,27 +711,32 @@ export function useDesktopShareBusiness() {
isViewing: state.isViewing,
connectionCode: state.connectionCode,
remoteStream: state.remoteStream,
localStream: state.localStream, // 使用状态中的流
error: state.error,
isWaitingForPeer: state.isWaitingForPeer,
isConnected: webRTC.isConnected,
isConnecting: webRTC.isConnecting,
isWebSocketConnected: webRTC.isWebSocketConnected,
isPeerConnected: webRTC.isPeerConnected,
isConnected: webRTC.getConnectState().isConnected,
isConnecting: webRTC.getConnectState().isConnecting,
isWebSocketConnected: webRTC.getConnectState().isWebSocketConnected,
isPeerConnected: webRTC.getConnectState().isPeerConnected,
// 新增表示是否可以开始共享WebSocket已连接且有房间代码
canStartSharing: webRTC.isWebSocketConnected && !!state.connectionCode,
canStartSharing: webRTC.getConnectState().isWebSocketConnected && !!state.connectionCode,
// 方法
createRoom, // 创建房间
createRoomAndStartSharing, // 创建房间并立即开始桌面共享
startSharing, // 选择桌面并建立P2P连接
switchDesktop, // 新增:切换桌面
stopSharing,
resetSharing, // 重置到初始状态,保留房间连接
handlePeerDisconnect, // 处理P2P断开但保持桌面共享
resumeSharing, // 重新建立P2P连接并推流
joinSharing,
stopViewing,
setRemoteVideoRef,
// WebRTC连接状态
webRTCError: webRTC.error,
webRTCError: webRTC.getConnectState().error,
// 暴露WebRTC连接对象
webRTCConnection: webRTC,
};

View File

@@ -1,4 +1,4 @@
import { useRef, useCallback, useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
interface FileInfo {
id: string;
@@ -15,7 +15,7 @@ interface UseFileListSyncProps {
pickupCode: string;
isConnected: boolean;
isPeerConnected: boolean;
getChannelState: () => string;
getChannelState: () => any;
}
export const useFileListSync = ({
@@ -31,11 +31,22 @@ export const useFileListSync = ({
// 统一的文件列表同步函数,带防抖功能
const syncFileListToReceiver = useCallback((fileInfos: FileInfo[], reason: string) => {
// 只有在发送模式、连接已建立且有房间时才发送文件列表
if (mode !== 'send' || !pickupCode || !isConnected || !isPeerConnected) {
console.log('跳过文件列表同步:', { mode, pickupCode: !!pickupCode, isConnected, isPeerConnected });
if (mode !== 'send' || !pickupCode) {
console.log('跳过文件列表同步: 非发送模式或无房间码', { mode, pickupCode: !!pickupCode });
return;
}
// 获取当前通道状态
const channelState = getChannelState();
console.log(`文件列表同步检查 (${reason}):`, {
mode,
pickupCode: !!pickupCode,
isConnected,
isPeerConnected,
channelState: channelState.state || channelState,
fileInfosCount: fileInfos.length
});
// 清除之前的延时发送
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
@@ -43,9 +54,27 @@ export const useFileListSync = ({
// 延时发送,避免频繁发送
syncTimeoutRef.current = setTimeout(() => {
if (isPeerConnected && getChannelState() === 'open') {
// 检查数据通道状态 - 使用更宽松的条件
const currentState = getChannelState();
const isChannelOpen = typeof currentState === 'object' ?
currentState.state === 'open' || currentState.isDataChannelConnected :
currentState === 'open';
// 检查P2P连接状态
const isP2PConnected = isPeerConnected || (typeof currentState === 'object' && currentState.isPeerConnected);
console.log(`文件列表同步执行检查 (${reason}):`, {
isChannelOpen,
isP2PConnected,
fileInfosCount: fileInfos.length
});
// 如果数据通道已打开或P2P已连接就可以发送文件列表
if (isChannelOpen || isP2PConnected) {
console.log(`发送文件列表到接收方 (${reason}):`, fileInfos.map(f => f.name));
sendFileList(fileInfos);
} else {
console.log(`跳过文件列表发送: 数据通道未打开或P2P未连接 (${reason})`);
}
}, 150);
}, [mode, pickupCode, isConnected, isPeerConnected, getChannelState, sendFileList]);

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
interface FileInfo {
id: string;
@@ -7,6 +7,8 @@ interface FileInfo {
type: string;
status: 'ready' | 'downloading' | 'completed';
progress: number;
transferSpeed?: number; // bytes per second
startTime?: number; // 传输开始时间
}
interface UseFileStateManagerProps {
@@ -35,10 +37,10 @@ export const useFileStateManager = ({
const handleFileSelect = useCallback((files: File[]) => {
console.log('=== 文件选择 ===');
console.log('新文件:', files.map(f => f.name));
// 更新选中的文件
setSelectedFiles(prev => [...prev, ...files]);
// 创建对应的文件信息
const newFileInfos: FileInfo[] = files.map(file => ({
id: generateFileId(),
@@ -48,7 +50,7 @@ export const useFileStateManager = ({
status: 'ready',
progress: 0
}));
setFileList(prev => {
const updatedList = [...prev, ...newFileInfos];
console.log('更新后的文件列表:', updatedList);
@@ -72,21 +74,32 @@ export const useFileStateManager = ({
}, []);
// 更新文件状态
const updateFileStatus = useCallback((fileId: string, status: FileInfo['status'], progress?: number) => {
setFileList(prev => prev.map(item =>
item.id === fileId
? { ...item, status, progress: progress ?? item.progress }
const updateFileStatus = useCallback((fileId: string, status: FileInfo['status'], progress?: number, transferSpeed?: number) => {
setFileList(prev => prev.map(item =>
item.id === fileId
? {
...item,
status,
progress: progress ?? item.progress,
transferSpeed: transferSpeed ?? item.transferSpeed,
startTime: status === 'downloading' && !item.startTime ? Date.now() : item.startTime
}
: item
));
}, []);
// 更新文件进度
const updateFileProgress = useCallback((fileId: string, fileName: string, progress: number) => {
const updateFileProgress = useCallback((fileId: string, fileName: string, progress: number, transferSpeed?: number) => {
const newStatus = progress >= 100 ? 'completed' as const : 'downloading' as const;
setFileList(prev => prev.map(item => {
if (item.id === fileId || item.name === fileName) {
console.log(`更新文件 ${item.name} 进度: ${item.progress} -> ${progress}`);
return { ...item, progress, status: newStatus };
return {
...item,
progress,
status: newStatus,
transferSpeed: transferSpeed ?? item.transferSpeed,
startTime: newStatus === 'downloading' && !item.startTime ? Date.now() : item.startTime
};
}
return item;
}));
@@ -115,40 +128,59 @@ export const useFileStateManager = ({
console.log('=== selectedFiles变化同步文件列表 ===', {
selectedFilesCount: selectedFiles.length,
fileListCount: fileList.length,
selectedFileNames: selectedFiles.map(f => f.name)
});
// 根据selectedFiles创建新的文件信息列表
const newFileInfos: FileInfo[] = selectedFiles.map(file => {
// 尝试找到现有的文件信息,保持已有的状态
const existingFileInfo = fileList.find(info => info.name === file.name && info.size === file.size);
return existingFileInfo || {
id: generateFileId(),
name: file.name,
size: file.size,
type: file.type,
status: 'ready' as const,
progress: 0
};
});
// 检查文件列表是否真正发生变化
const fileListChanged =
newFileInfos.length !== fileList.length ||
newFileInfos.some(newFile =>
!fileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
);
if (fileListChanged) {
console.log('文件列表发生变化,更新:', {
before: fileList.map(f => f.name),
after: newFileInfos.map(f => f.name)
// 使用函数式更新获取当前fileList避免依赖fileList
setFileList(currentFileList => {
// 根据selectedFiles创建新的文件信息列表
const newFileInfos: FileInfo[] = selectedFiles.map(file => {
// 尝试在当前fileList中找到现有的文件信息保持已有的状态
const existingFileInfo = currentFileList.find(info => info.name === file.name && info.size === file.size);
return existingFileInfo || {
id: generateFileId(),
name: file.name,
size: file.size,
type: file.type,
status: 'ready' as const,
progress: 0
};
});
setFileList(newFileInfos);
}
}, [selectedFiles, mode, pickupCode, fileList, generateFileId]);
// 检查文件列表是否真正发生变化
const fileListChanged =
newFileInfos.length !== currentFileList.length ||
newFileInfos.some(newFile =>
!currentFileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
);
if (fileListChanged) {
console.log('文件列表发生变化,更新:', {
before: currentFileList.map(f => f.name),
after: newFileInfos.map(f => f.name)
});
return newFileInfos;
}
// 如果没有变化返回当前的fileList
return currentFileList;
});
}, [selectedFiles, mode, pickupCode, generateFileId]); // 移除fileList依赖避免无限循环
// 清除发送方数据(当接收方离开房间时)
const clearSenderData = useCallback(() => {
console.log('[FileStateManager] 接收方离开房间,清除发送方数据');
// 只清除文件列表和传输状态,不清除选中的文件
// 这样用户可以重新连接后继续发送
setFileList(prev => prev.map(file => ({
...file,
status: 'ready' as const,
progress: 0,
transferSpeed: undefined,
startTime: undefined
})));
}, []);
return {
selectedFiles,
@@ -161,6 +193,7 @@ export const useFileStateManager = ({
clearFiles,
resetFiles,
updateFileStatus,
updateFileProgress
updateFileProgress,
clearSenderData
};
};

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { WebRTCConnection } from '../connection/useSharedWebRTCManager';
import { useCallback, useEffect, useRef, useState } from 'react';
import { type IWebConnection } from '../connection/types';
// 文件传输状态
interface FileTransferState {
@@ -75,11 +76,20 @@ type FileProgressCallback = (progressInfo: { fileId: string; fileName: string; p
type FileListReceivedCallback = (fileList: FileInfo[]) => void;
const CHANNEL_NAME = 'file-transfer';
const CHUNK_SIZE = 256 * 1024; // 256KB
const WEBRTC_CHUNK_SIZE = 256 * 1024; // 256KB for WebRTC
const WEBSOCKET_CHUNK_SIZE = 3 * 1024 * 1024; // 3MB for WebSocket
const MAX_RETRIES = 5; // 最大重试次数
const RETRY_DELAY = 1000; // 重试延迟(毫秒)
const ACK_TIMEOUT = 5000; // 确认超时(毫秒)
/**
* 根据连接类型获取块大小
*/
function getChunkSize(connectType: string): number {
return connectType === 'websocket' ? WEBSOCKET_CHUNK_SIZE : WEBRTC_CHUNK_SIZE;
}
/**
* 计算数据的CRC32校验和
*/
@@ -97,25 +107,12 @@ function calculateChecksum(data: ArrayBuffer): string {
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);
}
/**
* 文件传输业务层
* 必须传入共享的 WebRTC 连接
*/
export function useFileTransferBusiness(connection: WebRTCConnection) {
export function useFileTransferBusiness(connection: IWebConnection) {
const [state, setState] = useState<FileTransferState>({
isConnecting: false,
@@ -173,7 +170,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
});
// 初始化接收进度跟踪
const totalChunks = Math.ceil(metadata.size / CHUNK_SIZE);
const totalChunks = Math.ceil(metadata.size / getChunkSize(connection.connectType));
receiveProgress.current.set(metadata.id, {
fileId: metadata.id,
fileName: metadata.name,
@@ -301,10 +298,10 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
// 检查是否已经接收过这个块,避免重复计数
const alreadyReceived = fileInfo.chunks[chunkIndex] !== undefined;
// 数据有效,保存到缓存
fileInfo.chunks[chunkIndex] = data;
// 只有在首次接收时才增加计数
if (!alreadyReceived) {
fileInfo.receivedChunks++;
@@ -347,6 +344,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
}, [updateState, connection]);
const connectionRef = useRef(connection);
useEffect(() => {
connectionRef.current = connection;
}, [connection]);
@@ -366,12 +364,12 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
useEffect(() => {
// 同步连接状态
updateState({
isConnecting: connection.isConnecting,
isConnected: connection.isConnected,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error
isConnecting: connection.getConnectState().isConnecting,
isConnected: connection.getConnectState().isConnected,
isWebSocketConnected: connection.getConnectState().isWebSocketConnected,
connectionError: connection.getConnectState().error
});
}, [connection.isConnecting, connection.isConnected, connection.isWebSocketConnected, connection.error, updateState]);
}, [connection.getConnectState, updateState]);
// 连接
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
@@ -387,19 +385,26 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
retryCount = 0
): Promise<boolean> => {
return new Promise((resolve) => {
// 主要检查数据通道状态,因为数据通道是文件传输的实际通道
const channelState = connection.getChannelState();
if (channelState === 'closed') {
console.warn(`数据通道已关闭,停止发送文件块 ${chunkIndex}`);
// 改进数据传输前的连接状态检查
const channelState = connection.getConnectState();
const isChannelUsable =
channelState.state === 'open' ||
channelState.isDataChannelConnected ||
channelState.isPeerConnected ||
(channelState.isWebSocketConnected && channelState.currentConnectType === 'websocket');
if (!isChannelUsable) {
console.warn(`数据块发送失败,传输通道不可用 ${chunkIndex}:`, {
state: channelState.state,
isDataChannelConnected: channelState.isDataChannelConnected,
isPeerConnected: channelState.isPeerConnected,
isWebSocketConnected: channelState.isWebSocketConnected,
currentConnectType: channelState.currentConnectType
});
resolve(false);
return;
}
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
if (!connection.isConnected && channelState === 'connecting') {
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
}
const chunkKey = `${fileId}-${chunkIndex}`;
// 设置确认回调
@@ -445,15 +450,35 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
// 安全发送文件
const sendFileSecure = useCallback(async (file: File, fileId?: string) => {
if (connection.getChannelState() !== 'open') {
updateState({ error: '连接未就绪' });
// 改进连接状态检查 - 使用更全面的连接状态判断
const connectState = connection.getConnectState();
const isReadyToSend =
connectState.state === 'open' || // 数据通道已打开
connectState.isDataChannelConnected || // 数据通道已连接
connectState.isPeerConnected || // P2P连接已建立
(connectState.isWebSocketConnected && connectState.currentConnectType === 'websocket'); // WebSocket降级模式
console.log('发送文件前连接状态检查:', {
state: connectState.state,
isDataChannelConnected: connectState.isDataChannelConnected,
isPeerConnected: connectState.isPeerConnected,
isWebSocketConnected: connectState.isWebSocketConnected,
currentConnectType: connectState.currentConnectType,
isReadyToSend
});
if (!isReadyToSend) {
const errorMsg = `连接未就绪 - 状态: ${connectState.state}, 数据通道: ${connectState.isDataChannelConnected}, P2P: ${connectState.isPeerConnected}, WebSocket: ${connectState.isWebSocketConnected}`;
console.error(errorMsg);
updateState({ error: errorMsg });
return;
}
const actualFileId = fileId || `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const chunkSize = getChunkSize(connection.connectType);
const totalChunks = Math.ceil(file.size / chunkSize);
console.log('开始安全发送文件:', file.name, '文件ID:', actualFileId, '总块数:', totalChunks);
console.log('开始安全发送文件:', file.name, '文件ID:', actualFileId, '总块数:', totalChunks, '块大小:', chunkSize);
updateState({ isTransferring: true, progress: 0, error: null });
@@ -489,20 +514,27 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
let retryCount = 0;
while (!success && retryCount <= MAX_RETRIES) {
// 检查数据通道状态,这是文件传输的实际通道
const channelState = connection.getChannelState();
if (channelState === 'closed') {
console.warn(`数据通道已关闭,停止文件传输`);
throw new Error('数据通道已关闭');
// 改进传输过程中的连接状态检查
const channelState = connection.getConnectState();
const isChannelUsable =
channelState.state === 'open' ||
channelState.isDataChannelConnected ||
channelState.isPeerConnected ||
(channelState.isWebSocketConnected && channelState.currentConnectType === 'websocket');
if (!isChannelUsable) {
console.warn(`数据传输通道不可用,停止文件传输:`, {
state: channelState.state,
isDataChannelConnected: channelState.isDataChannelConnected,
isPeerConnected: channelState.isPeerConnected,
isWebSocketConnected: channelState.isWebSocketConnected,
currentConnectType: channelState.currentConnectType
});
throw new Error('数据传输通道不可用');
}
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
if (!connection.isConnected && channelState === 'connecting') {
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
}
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const arrayBuffer = await chunk.arrayBuffer();
const checksum = calculateChecksum(arrayBuffer);
@@ -553,8 +585,8 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
// 自适应流控:根据传输速度调整发送间隔
if (status.averageSpeed > 0) {
const chunkSize = Math.min(CHUNK_SIZE, file.size - chunkIndex * CHUNK_SIZE);
const expectedTime = (chunkSize / 1024) / status.averageSpeed;
const currentChunkSize = Math.min(chunkSize, file.size - chunkIndex * chunkSize);
const expectedTime = (currentChunkSize / 1024) / status.averageSpeed;
const actualTime = Date.now() - status.lastChunkTime;
const delay = Math.max(0, expectedTime - actualTime);
@@ -597,42 +629,83 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
// 发送文件列表
const sendFileList = useCallback((fileList: FileInfo[]) => {
// 检查连接状态 - 优先检查数据通道状态,因为 P2P 连接可能已经建立但状态未及时更新
const channelState = connection.getChannelState();
const peerConnected = connection.isPeerConnected;
// 改进连接状态检查逻辑
const channelState = connection.getConnectState();
const isReadyToSend =
channelState.state === 'open' ||
channelState.isDataChannelConnected ||
channelState.isPeerConnected ||
(channelState.isWebSocketConnected && channelState.currentConnectType === 'websocket') ||
channelState.isConnected;
console.log('发送文件列表检查:', {
channelState,
peerConnected,
state: channelState.state,
isDataChannelConnected: channelState.isDataChannelConnected,
isPeerConnected: channelState.isPeerConnected,
isWebSocketConnected: channelState.isWebSocketConnected,
currentConnectType: channelState.currentConnectType,
isConnected: channelState.isConnected,
isReadyToSend,
fileListLength: fileList.length
});
// 如果数据通道已打开或者 P2P 已连接,就可以发送文件列表
if (channelState === 'open' || peerConnected) {
console.log('发送文件列表:', fileList);
if (isReadyToSend) {
console.log('发送文件列表:', fileList.map(f => f.name));
connection.sendMessage({
const sendResult = connection.sendMessage({
type: 'file-list',
payload: fileList
}, CHANNEL_NAME);
if (!sendResult) {
console.warn('文件列表发送失败,可能是数据通道未准备好');
// 不立即重试,让上层逻辑处理重试
}
} else {
console.log('P2P连接未建立,等待连接后再发送文件列表');
console.log('连接未就绪,等待连接后再发送文件列表');
}
}, [connection]);
// 请求文件
const requestFile = useCallback((fileId: string, fileName: string) => {
if (connection.getChannelState() !== 'open') {
const channelState = connection.getConnectState();
// 统一的连接状态检查逻辑
const isReadyToRequest =
channelState.state === 'open' ||
channelState.isDataChannelConnected ||
channelState.isPeerConnected ||
(channelState.isWebSocketConnected && channelState.currentConnectType === 'websocket') ||
channelState.isConnected;
console.log('请求文件前检查连接状态:', {
fileName,
fileId,
state: channelState.state,
isDataChannelConnected: channelState.isDataChannelConnected,
isPeerConnected: channelState.isPeerConnected,
isWebSocketConnected: channelState.isWebSocketConnected,
currentConnectType: channelState.currentConnectType,
isConnected: channelState.isConnected,
isReadyToRequest
});
if (!isReadyToRequest) {
console.error('数据通道未准备就绪,无法请求文件');
return;
}
console.log('请求文件:', fileName, fileId);
console.log('发送文件请求:', fileName, fileId);
connection.sendMessage({
const sendResult = connection.sendMessage({
type: 'file-request',
payload: { fileId, fileName }
}, CHANNEL_NAME);
if (!sendResult) {
console.error('文件请求发送失败,可能是数据通道问题');
// 不立即重试,让上层逻辑处理重试
}
}, [connection]);
// 注册回调函数
@@ -656,6 +729,38 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
return () => { fileListCallbacks.current.delete(callback); };
}, []);
// 清除发送方数据
const clearSenderData = useCallback(() => {
console.log('[FileTransferBusiness] 清除发送方数据');
// 清除传输状态
transferStatus.current.clear();
// 清除待处理的块
pendingChunks.current.forEach(timeout => clearTimeout(timeout));
pendingChunks.current.clear();
// 清除块确认回调
chunkAckCallbacks.current.clear();
// 重置状态
updateState({
isTransferring: false,
progress: 0,
error: null
});
}, [updateState]);
// 设置断开连接回调
useEffect(() => {
connection.setOnDisconnectCallback(clearSenderData);
return () => {
// 清理回调
connection.setOnDisconnectCallback(() => { });
};
}, [connection, clearSenderData]);
return {
// 文件传输状态(包括连接状态)
...state,
@@ -666,6 +771,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
sendFile,
sendFileList,
requestFile,
clearSenderData,
// 回调注册
onFileReceived,

View File

@@ -15,5 +15,3 @@ export * from './text-transfer';
// UI状态管理相关
export * from './ui';
// 核心WebRTC功能
export * from './webrtc';

View File

@@ -0,0 +1,3 @@
export * from './useIceServersConfig';
export * from './useWebRTCConfigSync';
export * from './useWebRTCConfigSync';

View File

@@ -0,0 +1,251 @@
import { useState, useEffect, useCallback } from 'react';
export interface IceServerConfig {
id: string;
urls: string;
username?: string;
credential?: string;
type: 'stun' | 'turn';
enabled: boolean;
isDefault?: boolean; // 标记是否为默认服务器
}
const DEFAULT_ICE_SERVERS: IceServerConfig[] = [
{
id: 'easyvoip-stun',
urls: 'stun:stun.easyvoip.com:3478',
type: 'stun',
enabled: true,
isDefault: true,
},
{
id: 'miwifi-stun',
urls: 'stun:stun.miwifi.com:3478',
type: 'stun',
enabled: true,
isDefault: true,
},
{
id: 'google-stun-1',
urls: 'stun:stun.l.google.com:19302',
type: 'stun',
enabled: true,
isDefault: true,
},
{
id: 'google-stun-2',
urls: 'stun:stun1.l.google.com:19302',
type: 'stun',
enabled: true,
isDefault: true,
},
{
id: 'twilio-stun',
urls: 'stun:global.stun.twilio.com:3478',
type: 'stun',
enabled: true,
isDefault: true,
}
];
const STORAGE_KEY = 'webrtc-ice-servers-config-090901';
export function useIceServersConfig() {
const [iceServers, setIceServers] = useState<IceServerConfig[]>([]);
const [isLoading, setIsLoading] = useState(true);
// 加载配置
const loadConfig = useCallback(() => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEY);
if (savedConfig) {
const parsed = JSON.parse(savedConfig);
// 确保所有服务器都有isDefault属性
const serversWithDefaults = parsed.map((server: any) => ({
...server,
isDefault: server.isDefault !== undefined ? server.isDefault :
DEFAULT_ICE_SERVERS.some(defaultServer => defaultServer.id === server.id)
}));
setIceServers(serversWithDefaults);
} else {
setIceServers(DEFAULT_ICE_SERVERS);
}
} catch (error) {
console.error('加载ICE服务器配置失败:', error);
setIceServers(DEFAULT_ICE_SERVERS);
} finally {
setIsLoading(false);
}
}, []);
// 保存配置
const saveConfig = useCallback((servers: IceServerConfig[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(servers));
setIceServers(servers);
} catch (error) {
console.error('保存ICE服务器配置失败:', error);
throw new Error('保存配置失败');
}
}, []);
// 添加服务器
const addIceServer = useCallback((config: Omit<IceServerConfig, 'id'>) => {
const newServer: IceServerConfig = {
...config,
id: `custom-${Date.now()}`,
isDefault: false, // 用户添加的服务器不标记为默认
};
const updatedServers = [...iceServers, newServer];
saveConfig(updatedServers);
}, [iceServers, saveConfig]);
// 更新服务器
const updateIceServer = useCallback((id: string, updates: Partial<IceServerConfig>) => {
const updatedServers = iceServers.map(server =>
server.id === id ? { ...server, ...updates } : server
);
saveConfig(updatedServers);
}, [iceServers, saveConfig]);
// 删除服务器
const removeIceServer = useCallback((id: string) => {
// 确保至少保留一个服务器
if (iceServers.length <= 1) {
throw new Error('至少需要保留一个ICE服务器');
}
const updatedServers = iceServers.filter(server => server.id !== id);
saveConfig(updatedServers);
}, [iceServers, saveConfig]);
// 恢复默认配置
const resetToDefault = useCallback(() => {
saveConfig(DEFAULT_ICE_SERVERS);
}, [saveConfig]);
// 获取WebRTC格式的配置
const getWebRTCConfig = useCallback((): RTCIceServer[] => {
return iceServers
.filter(server => server.enabled)
.map(server => {
const rtcServer: RTCIceServer = {
urls: server.urls,
};
if (server.username) {
rtcServer.username = server.username;
}
if (server.credential) {
rtcServer.credential = server.credential;
}
return rtcServer;
});
}, [iceServers]);
// 验证服务器配置
const validateServer = useCallback((config: Omit<IceServerConfig, 'id'>) => {
const errors: string[] = [];
if (!config.urls.trim()) {
errors.push('服务器地址不能为空');
} else {
// 基本URL格式验证
const urlPattern = /^(stun|turn|turns):.+/i;
if (!urlPattern.test(config.urls)) {
errors.push('服务器地址格式不正确(应以 stun: 或 turn: 开头)');
}
}
if (config.type === 'turn') {
if (!config.username?.trim()) {
errors.push('TURN服务器需要用户名');
}
if (!config.credential?.trim()) {
errors.push('TURN服务器需要密码');
}
}
return errors;
}, []);
// 初始化加载
useEffect(() => {
loadConfig();
}, [loadConfig]);
return {
iceServers,
isLoading,
addIceServer,
updateIceServer,
removeIceServer,
resetToDefault,
getWebRTCConfig,
validateServer,
saveConfig,
};
}
// 独立的函数用于在非React组件中获取ICE服务器配置
export function getIceServersConfig(): RTCIceServer[] {
if (typeof window === 'undefined') {
// 服务器端默认配置
return [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
];
}
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) {
// 返回默认配置的WebRTC格式
return DEFAULT_ICE_SERVERS
.filter(server => server.enabled)
.map(server => {
const rtcServer: RTCIceServer = {
urls: server.urls,
};
if (server.username) {
rtcServer.username = server.username;
}
if (server.credential) {
rtcServer.credential = server.credential;
}
return rtcServer;
});
}
const iceServers: IceServerConfig[] = JSON.parse(saved);
return iceServers
.filter(server => server.enabled)
.map(server => {
const rtcServer: RTCIceServer = {
urls: server.urls,
};
if (server.username) {
rtcServer.username = server.username;
}
if (server.credential) {
rtcServer.credential = server.credential;
}
return rtcServer;
});
} catch (error) {
console.error('获取ICE服务器配置失败:', error);
// 发生错误时返回默认配置
return [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
];
}
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useCallback } from 'react';
import { useToast } from '@/components/ui/toast-simple';
export function useWebRTCConfigSync() {
const { showToast } = useToast();
// 监听存储变化事件
const handleStorageChange = useCallback((event: StorageEvent) => {
if (event.key === 'webrtc-ice-servers-config') {
showToast(
'检测到WebRTC配置更改请重新建立连接以应用新配置',
'info'
);
}
}, [showToast]);
useEffect(() => {
// 监听localStorage变化
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [handleStorageChange]);
return {
// 可以在这里添加其他配置同步相关的方法
};
}

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { WebRTCConnection } from '../connection/useSharedWebRTCManager';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useWebConnectStateManager } from '../connection/state/useWebConnectStateManager';
import { Role, type IWebConnection } from '../connection/types';
// 文本传输状态
interface TextTransferState {
@@ -21,7 +22,7 @@ const CHANNEL_NAME = 'text-transfer';
* 文本传输业务层
* 必须传入共享的 WebRTC 连接
*/
export function useTextTransferBusiness(connection: WebRTCConnection) {
export function useTextTransferBusiness(connection: IWebConnection) {
const [state, setState] = useState<TextTransferState>({
isConnecting: false,
isConnected: false,
@@ -31,6 +32,8 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
isTyping: false
});
const connectState = useWebConnectStateManager(); // 确保状态管理器被初始化
// 回调引用
const textSyncCallbackRef = useRef<TextSyncCallback | null>(null);
const typingCallbackRef = useRef<TypingStatusCallback | null>(null);
@@ -86,15 +89,15 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
useEffect(() => {
// 同步连接状态
updateState({
isConnecting: connection.isConnecting,
isConnected: connection.isConnected,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error
isConnecting: connectState.getState().isConnecting,
isConnected: connectState.getState().isConnected,
isWebSocketConnected: connectState.getState().isWebSocketConnected,
connectionError: connectState.getState().error
});
}, [connection.isConnecting, connection.isConnected, connection.isWebSocketConnected, connection.error, updateState]);
}, [connectState.getState, updateState]);
// 连接
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
const connect = useCallback((roomCode: string, role: Role) => {
return connection.connect(roomCode, role);
}, [connection]);
@@ -105,7 +108,7 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
// 发送实时文本同步 (替代原来的 sendMessage)
const sendTextSync = useCallback((text: string) => {
if (!connection || !connection.isPeerConnected) return;
if (!connectState.getState().isConnected || !connection.getConnectState().isPeerConnected) return;
const message = {
type: 'text-sync',
@@ -116,11 +119,11 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
if (success) {
console.log('发送实时文本同步:', text.length, '字符');
}
}, [connection]);
}, [connectState.getState]);
// 发送打字状态
const sendTypingStatus = useCallback((isTyping: boolean) => {
if (!connection || !connection.isPeerConnected) return;
if (!connection || !connection.getConnectState().isPeerConnected) return;
const message = {
type: 'text-typing',
@@ -155,10 +158,10 @@ export function useTextTransferBusiness(connection: WebRTCConnection) {
return {
// 状态 - 直接从 connection 获取
isConnecting: connection.isConnecting,
isConnected: connection.isConnected,
isWebSocketConnected: connection.isWebSocketConnected,
connectionError: connection.error,
isConnecting: connection.getConnectState().isConnecting,
isConnected: connection.getConnectState().isConnected,
isWebSocketConnected: connection.getConnectState().isWebSocketConnected,
connectionError: connection.getConnectState().error,
currentText: state.currentText,
isTyping: state.isTyping,

View File

@@ -1,5 +1,6 @@
// UI状态管理相关的hooks
export { useURLHandler } from './useURLHandler';
export { useWebRTCStore } from './webRTCStore';
export { useWebRTCStore } from '../connection/state/webConnectStore';
export { useTabNavigation } from './useTabNavigation';
export type { TabType } from './useTabNavigation';
export { useURLHandler } from './useURLHandler';

View File

@@ -1,18 +1,20 @@
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { useURLHandler, FeatureType } from './useURLHandler';
import { useWebRTCStore } from './webRTCStore';
import { useCallback, useEffect, useState } from 'react';
import { useConnectManager } from '../connection';
import { useWebRTCStore } from '../connection/state/webConnectStore';
import { useConfirmDialog } from './useConfirmDialog';
import { FeatureType, useURLHandler } from './useURLHandler';
// Tab类型定义包括非WebRTC功能
export type TabType = 'webrtc' | 'message' | 'desktop' | 'wechat';
export type TabType = 'webrtc' | 'message' | 'desktop' | 'wechat' | 'settings';
// Tab显示名称
const TAB_NAMES: Record<TabType, string> = {
webrtc: '文件传输',
message: '文字传输',
desktop: '桌面共享',
wechat: '微信群'
wechat: '微信群',
settings: '设置'
};
// WebRTC功能的映射
@@ -27,31 +29,33 @@ export const useTabNavigation = () => {
const [activeTab, setActiveTab] = useState<TabType>('webrtc');
const [hasInitialized, setHasInitialized] = useState(false);
const { showConfirmDialog, dialogState, closeDialog } = useConfirmDialog();
// 获取WebRTC全局状态
const {
isConnected,
isConnecting,
const {
isConnected,
isConnecting,
isPeerConnected,
currentRoom,
reset: resetWebRTCState
} = useWebRTCStore();
// 获取WebRTC连接管理器
const { disconnect: disconnectWebRTC } = useConnectManager();
// 创建一个通用的URL处理器用于断开连接
const { hasActiveConnection } = useURLHandler({
featureType: 'webrtc', // 默认值,实际使用时会被覆盖
onModeChange: () => {},
onModeChange: () => { },
});
// 根据URL参数设置初始标签仅首次加载时
useEffect(() => {
if (!hasInitialized) {
const urlType = searchParams.get('type');
console.log('=== HomePage URL处理 ===');
console.log('URL type参数:', urlType);
console.log('所有搜索参数:', Object.fromEntries(searchParams.entries()));
// 将旧的text类型重定向到message
if (urlType === 'text') {
console.log('检测到text类型重定向到message标签页');
@@ -66,7 +70,7 @@ export const useTabNavigation = () => {
console.log('没有有效的type参数使用默认标签页webrtc文件传输');
// 保持默认的webrtc标签
}
setHasInitialized(true);
}
}, [searchParams, hasInitialized]);
@@ -75,29 +79,37 @@ export const useTabNavigation = () => {
const handleTabChange = useCallback(async (newTab: TabType) => {
console.log('=== Tab切换 ===');
console.log('当前tab:', activeTab, '目标tab:', newTab);
// 如果切换到wechat tab非WebRTC功能可以直接切换
if (newTab === 'wechat') {
// 如果有活跃连接,需要确认
if (hasActiveConnection()) {
const currentTabName = TAB_NAMES[activeTab];
const confirmed = await showConfirmDialog({
title: '切换功能确认',
message: `切换到微信群功能需要关闭当前的${currentTabName}连接,是否继续?`,
confirmText: '确认切换',
cancelText: '取消',
type: 'warning'
});
if (!confirmed) {
return false;
}
// 断开连接并清除状态
resetWebRTCState();
console.log('已清除WebRTC连接状态切换到微信群');
// 对于任何非WebRTC功能的tabwechat、settings如果有活跃连接需要确认
if ((newTab === 'wechat' || newTab === 'settings') && hasActiveConnection()) {
const currentTabName = TAB_NAMES[activeTab];
const targetTabName = TAB_NAMES[newTab];
const confirmed = await showConfirmDialog({
title: '切换功能确认',
message: `切换到${targetTabName}需要断开当前的${currentTabName}连接,是否继续?`,
confirmText: '确认切换',
cancelText: '取消',
type: 'warning'
});
if (!confirmed) {
return false;
}
// 断开连接并清除状态
disconnectWebRTC();
console.log(`已清除WebRTC连接状态切换到${targetTabName}`);
setActiveTab(newTab);
// 清除URL参数
const newUrl = new URL(window.location.href);
newUrl.search = '';
window.history.pushState({}, '', newUrl.toString());
return true;
}
// 如果切换到非活跃连接的wechat或settings tab直接切换
if (newTab === 'wechat' || newTab === 'settings') {
setActiveTab(newTab);
// 清除URL参数
const newUrl = new URL(window.location.href);
@@ -110,7 +122,7 @@ export const useTabNavigation = () => {
if (hasActiveConnection() && newTab !== activeTab && WEBRTC_FEATURES[newTab]) {
const currentTabName = TAB_NAMES[activeTab];
const targetTabName = TAB_NAMES[newTab];
const confirmed = await showConfirmDialog({
title: '切换功能确认',
message: `切换到${targetTabName}功能需要关闭当前的${currentTabName}连接,是否继续?`,
@@ -118,19 +130,19 @@ export const useTabNavigation = () => {
cancelText: '取消',
type: 'warning'
});
if (!confirmed) {
return false;
}
// 用户确认后重置WebRTC状态
resetWebRTCState();
disconnectWebRTC();
console.log(`已断开${currentTabName}连接,切换到${targetTabName}`);
}
// 执行tab切换
setActiveTab(newTab);
// 更新URL对于WebRTC功能
if (WEBRTC_FEATURES[newTab]) {
const params = new URLSearchParams();
@@ -144,9 +156,9 @@ export const useTabNavigation = () => {
newUrl.search = '';
window.history.pushState({}, '', newUrl.toString());
}
return true;
}, [activeTab, hasActiveConnection, resetWebRTCState]);
}, [activeTab, hasActiveConnection, disconnectWebRTC]);
// 获取连接状态信息
const getConnectionInfo = useCallback(() => {

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useToast } from '@/components/ui/toast-simple';
import { useWebRTCStore } from './webRTCStore';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useWebRTCStore } from '../connection/state/webConnectStore';
import { useConfirmDialog } from './useConfirmDialog';
// 支持的功能类型

View File

@@ -1,46 +0,0 @@
import { create } from 'zustand';
interface WebRTCState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean;
error: string | null;
canRetry: boolean; // 新增:是否可以重试
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;
resetToInitial: () => void; // 新增:完全重置到初始状态
}
const initialState: WebRTCState = {
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
canRetry: false, // 初始状态下不需要重试
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),
resetToInitial: () => set(initialState), // 完全重置到初始状态
}));

View File

@@ -1,227 +0,0 @@
import { EventEmitter } from 'events';
import { WebRTCError, WebRTCMessage, ConnectionEvent, EventHandler, MessageHandler, DataHandler } from './types';
interface DataChannelManagerConfig {
channelName: string;
onMessage?: MessageHandler;
onData?: DataHandler;
ordered?: boolean;
maxRetransmits?: number;
}
export class DataChannelManager extends EventEmitter {
private dataChannel: RTCDataChannel | null = null;
private config: DataChannelManagerConfig;
private messageQueue: WebRTCMessage[] = [];
private dataQueue: ArrayBuffer[] = [];
private isReady = false;
constructor(config: DataChannelManagerConfig) {
super();
this.config = {
ordered: true,
maxRetransmits: 3,
...config
};
}
initializeDataChannel(dataChannel: RTCDataChannel): void {
this.dataChannel = dataChannel;
this.setupEventHandlers();
}
createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
if (this.dataChannel) {
throw new WebRTCError('DC_ALREADY_EXISTS', '数据通道已存在', false);
}
try {
this.dataChannel = pc.createDataChannel(this.config.channelName, {
ordered: this.config.ordered,
maxRetransmits: this.config.maxRetransmits,
});
this.setupEventHandlers();
return this.dataChannel;
} catch (error) {
throw new WebRTCError('DC_CREATE_FAILED', '创建数据通道失败', false, error as Error);
}
}
private setupEventHandlers(): void {
if (!this.dataChannel) return;
this.dataChannel.onopen = () => {
console.log(`[DataChannel] 数据通道已打开: ${this.config.channelName}`);
this.isReady = true;
this.flushQueues();
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
};
this.dataChannel.onmessage = (event) => {
if (typeof event.data === 'string') {
try {
const message = JSON.parse(event.data) as WebRTCMessage;
console.log(`[DataChannel] 收到消息: ${message.type}, 通道: ${message.channel || this.config.channelName}`);
if (this.config.onMessage) {
this.config.onMessage(message);
}
} catch (error) {
console.error('[DataChannel] 解析消息失败:', error);
this.emit('error', {
type: 'error',
error: new WebRTCError('DC_MESSAGE_PARSE_ERROR', '消息解析失败', false, error as Error)
});
}
} else if (event.data instanceof ArrayBuffer) {
console.log(`[DataChannel] 收到数据: ${event.data.byteLength} bytes`);
if (this.config.onData) {
this.config.onData(event.data);
}
}
};
this.dataChannel.onerror = (error) => {
console.error(`[DataChannel] 数据通道错误: ${this.config.channelName}`, error);
const errorMessage = this.getDetailedErrorMessage();
this.emit('error', {
type: 'error',
error: new WebRTCError('DC_ERROR', errorMessage, true)
});
};
this.dataChannel.onclose = () => {
console.log(`[DataChannel] 数据通道已关闭: ${this.config.channelName}`);
this.isReady = false;
this.emit('disconnected', {
type: 'disconnected',
reason: `数据通道关闭: ${this.config.channelName}`
});
};
}
private getDetailedErrorMessage(): string {
if (!this.dataChannel) return '数据通道不可用';
switch (this.dataChannel.readyState) {
case 'connecting':
return '数据通道正在连接中,请稍候...';
case 'closing':
return '数据通道正在关闭,连接即将断开';
case 'closed':
return '数据通道已关闭P2P连接失败';
default:
return '数据通道连接失败,可能是网络环境受限';
}
}
sendMessage(message: WebRTCMessage): boolean {
if (!this.isReady || !this.dataChannel || this.dataChannel.readyState !== 'open') {
this.messageQueue.push(message);
return false;
}
try {
this.dataChannel.send(JSON.stringify(message));
console.log(`[DataChannel] 发送消息: ${message.type}, 通道: ${message.channel || this.config.channelName}`);
return true;
} catch (error) {
console.error('[DataChannel] 发送消息失败:', error);
this.emit('error', {
type: 'error',
error: new WebRTCError('DC_SEND_ERROR', '发送消息失败', true, error as Error)
});
return false;
}
}
sendData(data: ArrayBuffer): boolean {
if (!this.isReady || !this.dataChannel || this.dataChannel.readyState !== 'open') {
this.dataQueue.push(data);
return false;
}
try {
this.dataChannel.send(data);
console.log(`[DataChannel] 发送数据: ${data.byteLength} bytes`);
return true;
} catch (error) {
console.error('[DataChannel] 发送数据失败:', error);
this.emit('error', {
type: 'error',
error: new WebRTCError('DC_SEND_DATA_ERROR', '发送数据失败', true, error as Error)
});
return false;
}
}
private flushQueues(): void {
// 发送排队的消息
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
if (message) {
this.sendMessage(message);
}
}
// 发送排队的数据
while (this.dataQueue.length > 0) {
const data = this.dataQueue.shift();
if (data) {
this.sendData(data);
}
}
}
getState(): RTCDataChannelState {
return this.dataChannel?.readyState || 'closed';
}
isChannelReady(): boolean {
return this.isReady && this.dataChannel?.readyState === 'open';
}
getBufferedAmount(): number {
return this.dataChannel?.bufferedAmount || 0;
}
getBufferedAmountLowThreshold(): number {
return this.dataChannel?.bufferedAmountLowThreshold || 0;
}
setBufferedAmountLowThreshold(threshold: number): void {
if (this.dataChannel) {
this.dataChannel.bufferedAmountLowThreshold = threshold;
}
}
onBufferedAmountLow(handler: () => void): void {
if (this.dataChannel) {
this.dataChannel.onbufferedamountlow = handler;
}
}
close(): void {
if (this.dataChannel) {
this.dataChannel.close();
this.dataChannel = null;
}
this.isReady = false;
this.messageQueue = [];
this.dataQueue = [];
this.emit('disconnected', { type: 'disconnected', reason: '数据通道已关闭' });
}
on(event: string, handler: EventHandler<ConnectionEvent>): this {
return super.on(event, handler);
}
emit(eventName: string, event?: ConnectionEvent): boolean {
return super.emit(eventName, event);
}
}

View File

@@ -1,197 +0,0 @@
import { WebRTCMessage, MessageHandler, DataHandler } from './types';
interface ChannelHandlers {
messageHandlers: Set<MessageHandler>;
dataHandlers: Set<DataHandler>;
}
export class MessageRouter {
private channels = new Map<string, ChannelHandlers>();
private defaultChannelHandlers: ChannelHandlers | null = null;
constructor() {
this.createDefaultChannel();
}
private createDefaultChannel(): void {
this.defaultChannelHandlers = {
messageHandlers: new Set(),
dataHandlers: new Set(),
};
}
registerMessageHandler(channel: string, handler: MessageHandler): () => void {
let channelHandlers = this.channels.get(channel);
if (!channelHandlers) {
channelHandlers = {
messageHandlers: new Set(),
dataHandlers: new Set(),
};
this.channels.set(channel, channelHandlers);
}
channelHandlers.messageHandlers.add(handler);
// 返回取消注册函数
return () => {
channelHandlers!.messageHandlers.delete(handler);
// 如果通道没有处理器了,删除通道
if (channelHandlers!.messageHandlers.size === 0 && channelHandlers!.dataHandlers.size === 0) {
this.channels.delete(channel);
}
};
}
registerDataHandler(channel: string, handler: DataHandler): () => void {
let channelHandlers = this.channels.get(channel);
if (!channelHandlers) {
channelHandlers = {
messageHandlers: new Set(),
dataHandlers: new Set(),
};
this.channels.set(channel, channelHandlers);
}
channelHandlers.dataHandlers.add(handler);
// 返回取消注册函数
return () => {
channelHandlers!.dataHandlers.delete(handler);
// 如果通道没有处理器了,删除通道
if (channelHandlers!.messageHandlers.size === 0 && channelHandlers!.dataHandlers.size === 0) {
this.channels.delete(channel);
}
};
}
registerDefaultMessageHandler(handler: MessageHandler): () => void {
if (!this.defaultChannelHandlers) {
this.createDefaultChannel();
}
this.defaultChannelHandlers?.messageHandlers.add(handler);
return () => {
this.defaultChannelHandlers?.messageHandlers.delete(handler);
};
}
registerDefaultDataHandler(handler: DataHandler): () => void {
if (!this.defaultChannelHandlers) {
this.createDefaultChannel();
}
this.defaultChannelHandlers?.dataHandlers.add(handler);
return () => {
this.defaultChannelHandlers?.dataHandlers.delete(handler);
};
}
routeMessage(message: WebRTCMessage): void {
const channel = message.channel;
if (channel) {
// 路由到特定通道
const channelHandlers = this.channels.get(channel);
if (channelHandlers && channelHandlers.messageHandlers.size > 0) {
channelHandlers.messageHandlers.forEach(handler => {
try {
handler(message);
} catch (error) {
console.error(`消息处理器错误 (通道: ${channel}):`, error);
}
});
return;
}
}
// 回退到默认处理器
if (this.defaultChannelHandlers?.messageHandlers.size) {
this.defaultChannelHandlers.messageHandlers.forEach(handler => {
try {
handler(message);
} catch (error) {
console.error('默认消息处理器错误:', error);
}
});
} else {
console.warn('没有找到消息处理器:', message.type, channel || 'default');
}
}
routeData(data: ArrayBuffer, channel?: string): void {
if (channel) {
// 路由到特定通道
const channelHandlers = this.channels.get(channel);
if (channelHandlers && channelHandlers.dataHandlers.size > 0) {
channelHandlers.dataHandlers.forEach(handler => {
try {
handler(data);
} catch (error) {
console.error(`数据处理器错误 (通道: ${channel}):`, error);
}
});
return;
}
}
// 回退到默认处理器
if (this.defaultChannelHandlers?.dataHandlers.size) {
this.defaultChannelHandlers.dataHandlers.forEach(handler => {
try {
handler(data);
} catch (error) {
console.error('默认数据处理器错误:', error);
}
});
} else {
console.warn('没有找到数据处理器,数据大小:', data.byteLength, 'bytes');
}
}
hasHandlers(channel?: string): boolean {
if (channel) {
const channelHandlers = this.channels.get(channel);
return channelHandlers ?
(channelHandlers.messageHandlers.size > 0 || channelHandlers.dataHandlers.size > 0) :
false;
}
return this.defaultChannelHandlers ?
(this.defaultChannelHandlers.messageHandlers.size > 0 || this.defaultChannelHandlers.dataHandlers.size > 0) :
false;
}
getChannelList(): string[] {
return Array.from(this.channels.keys());
}
getHandlerCount(channel?: string): { message: number; data: number } {
if (channel) {
const channelHandlers = this.channels.get(channel);
return channelHandlers ? {
message: channelHandlers.messageHandlers.size,
data: channelHandlers.dataHandlers.size,
} : { message: 0, data: 0 };
}
return this.defaultChannelHandlers ? {
message: this.defaultChannelHandlers.messageHandlers.size,
data: this.defaultChannelHandlers.dataHandlers.size,
} : { message: 0, data: 0 };
}
clear(): void {
this.channels.clear();
this.createDefaultChannel();
}
clearChannel(channel: string): void {
this.channels.delete(channel);
}
}

View File

@@ -1,293 +0,0 @@
import { EventEmitter } from 'events';
import { WebRTCError, WebRTCConfig, ConnectionEvent, EventHandler } from './types';
interface PeerConnectionManagerConfig extends WebRTCConfig {
onSignalingMessage: (message: any) => void;
onTrack?: (event: RTCTrackEvent) => void;
}
interface NegotiationOptions {
offerToReceiveAudio?: boolean;
offerToReceiveVideo?: boolean;
}
export class PeerConnectionManager extends EventEmitter {
private pc: RTCPeerConnection | null = null;
private config: PeerConnectionManagerConfig;
private isNegotiating = false;
private negotiationQueue: Array<() => Promise<void>> = [];
private localCandidates: RTCIceCandidate[] = [];
private remoteCandidates: RTCIceCandidate[] = [];
constructor(config: PeerConnectionManagerConfig) {
super();
this.config = config;
}
async createPeerConnection(): Promise<RTCPeerConnection> {
if (this.pc) {
this.destroyPeerConnection();
}
try {
this.pc = new RTCPeerConnection({
iceServers: this.config.iceServers,
iceCandidatePoolSize: this.config.iceCandidatePoolSize,
});
this.setupEventHandlers();
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
return this.pc;
} catch (error) {
throw new WebRTCError('PC_CREATE_FAILED', '创建PeerConnection失败', true, error as Error);
}
}
private setupEventHandlers(): void {
if (!this.pc) return;
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.localCandidates.push(event.candidate);
this.config.onSignalingMessage({
type: 'ice-candidate',
payload: event.candidate
});
} else {
console.log('[PeerConnection] ICE收集完成');
}
};
this.pc.oniceconnectionstatechange = () => {
console.log('[PeerConnection] ICE连接状态:', this.pc!.iceConnectionState);
switch (this.pc!.iceConnectionState) {
case 'connected':
case 'completed':
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
break;
case 'failed':
this.emit('error', {
type: 'error',
error: new WebRTCError('ICE_FAILED', 'ICE连接失败', true)
});
break;
case 'disconnected':
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
break;
}
};
this.pc.onconnectionstatechange = () => {
console.log('[PeerConnection] 连接状态:', this.pc!.connectionState);
switch (this.pc!.connectionState) {
case 'connected':
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: true, error: null } });
break;
case 'failed':
this.emit('error', {
type: 'error',
error: new WebRTCError('CONNECTION_FAILED', 'WebRTC连接失败', true)
});
break;
case 'disconnected':
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
break;
}
};
this.pc.ontrack = (event) => {
console.log('[PeerConnection] 收到轨道:', event.track.kind);
if (this.config.onTrack) {
this.config.onTrack(event);
}
};
this.pc.onsignalingstatechange = () => {
console.log('[PeerConnection] 信令状态:', this.pc!.signalingState);
if (this.pc!.signalingState === 'stable') {
this.isNegotiating = false;
this.processNegotiationQueue();
}
};
}
async createOffer(options: NegotiationOptions = {}): Promise<RTCSessionDescriptionInit> {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
try {
const offerOptions: RTCOfferOptions = {
offerToReceiveAudio: options.offerToReceiveAudio ?? true,
offerToReceiveVideo: options.offerToReceiveVideo ?? true,
};
const offer = await this.pc.createOffer(offerOptions);
await this.pc.setLocalDescription(offer);
return offer;
} catch (error) {
throw new WebRTCError('OFFER_FAILED', '创建Offer失败', true, error as Error);
}
}
async createAnswer(): Promise<RTCSessionDescriptionInit> {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
try {
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
return answer;
} catch (error) {
throw new WebRTCError('ANSWER_FAILED', '创建Answer失败', true, error as Error);
}
}
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
try {
await this.pc.setRemoteDescription(description);
// 添加缓存的远程候选
for (const candidate of this.remoteCandidates) {
await this.pc.addIceCandidate(candidate);
}
this.remoteCandidates = [];
} catch (error) {
throw new WebRTCError('REMOTE_DESC_FAILED', '设置远程描述失败', false, error as Error);
}
}
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
if (!this.pc) {
this.remoteCandidates.push(new RTCIceCandidate(candidate));
return;
}
try {
await this.pc.addIceCandidate(candidate);
} catch (error) {
console.warn('添加ICE候选失败:', error);
}
}
addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSender | null {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
try {
return this.pc.addTrack(track, stream);
} catch (error) {
throw new WebRTCError('ADD_TRACK_FAILED', '添加轨道失败', false, error as Error);
}
}
removeTrack(sender: RTCRtpSender): void {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
try {
this.pc.removeTrack(sender);
} catch (error) {
throw new WebRTCError('REMOVE_TRACK_FAILED', '移除轨道失败', false, error as Error);
}
}
createDataChannel(label: string, options?: RTCDataChannelInit): RTCDataChannel {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
return this.pc.createDataChannel(label, options);
}
async renegotiate(options: NegotiationOptions = {}): Promise<void> {
if (!this.pc || this.isNegotiating) {
this.negotiationQueue.push(() => this.doRenegotiate(options));
return;
}
await this.doRenegotiate(options);
}
private async doRenegotiate(options: NegotiationOptions): Promise<void> {
if (!this.pc || this.isNegotiating) return;
this.isNegotiating = true;
try {
const offer = await this.createOffer(options);
this.config.onSignalingMessage({
type: 'offer',
payload: offer
});
} catch (error) {
this.isNegotiating = false;
throw error;
}
}
private processNegotiationQueue(): void {
if (this.negotiationQueue.length === 0) return;
const nextNegotiation = this.negotiationQueue.shift();
if (nextNegotiation) {
nextNegotiation().catch(error => {
console.error('处理协商队列失败:', error);
});
}
}
getStats(): Promise<RTCStatsReport> {
if (!this.pc) {
throw new WebRTCError('PC_NOT_READY', 'PeerConnection未准备就绪', false);
}
return this.pc.getStats();
}
getConnectionState(): RTCPeerConnectionState {
return this.pc?.connectionState || 'closed';
}
getIceConnectionState(): RTCIceConnectionState {
return this.pc?.iceConnectionState || 'closed';
}
getSignalingState(): RTCSignalingState {
return this.pc?.signalingState || 'stable';
}
destroyPeerConnection(): void {
if (this.pc) {
this.pc.close();
this.pc = null;
}
this.isNegotiating = false;
this.negotiationQueue = [];
this.localCandidates = [];
this.remoteCandidates = [];
this.emit('state-change', { type: 'state-change', state: { isPeerConnected: false } });
}
on(event: string, handler: EventHandler<ConnectionEvent>): this {
return super.on(event, handler);
}
emit(eventName: string, event?: ConnectionEvent): boolean {
return super.emit(eventName, event);
}
}

View File

@@ -1,455 +0,0 @@
import { EventEmitter } from 'events';
import { WebSocketManager } from './WebSocketManager';
import { PeerConnectionManager } from './PeerConnectionManager';
import { DataChannelManager } from './DataChannelManager';
import { MessageRouter } from './MessageRouter';
import {
WebRTCConnectionState,
WebRTCMessage,
WebRTCConfig,
WebRTCError,
MessageHandler,
DataHandler
} from './types';
import { getWsUrl } from '@/lib/config';
interface WebRTCManagerConfig extends Partial<WebRTCConfig> {
dataChannelName?: string;
enableLogging?: boolean;
}
interface SignalingMessage {
type: string;
payload: any;
}
export class WebRTCManager extends EventEmitter {
private wsManager: WebSocketManager;
private pcManager: PeerConnectionManager;
private dcManager: DataChannelManager;
private messageRouter: MessageRouter;
private config: WebRTCManagerConfig;
private state: WebRTCConnectionState;
private currentRoom: { code: string; role: 'sender' | 'receiver' } | null = null;
private isUserDisconnecting = false;
private abortController = new AbortController();
constructor(config: WebRTCManagerConfig = {}) {
super();
this.config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:global.stun.twilio.com:3478' },
],
iceCandidatePoolSize: 10,
chunkSize: 256 * 1024,
maxRetries: 5,
retryDelay: 1000,
ackTimeout: 5000,
dataChannelName: 'shared-channel',
enableLogging: true,
...config
};
this.state = {
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
canRetry: false,
currentRoom: null,
};
// 初始化各个管理器
this.wsManager = new WebSocketManager({
url: '',
reconnectAttempts: 5,
reconnectDelay: 1000,
timeout: 10000,
});
this.pcManager = new PeerConnectionManager({
iceServers: this.config.iceServers!,
iceCandidatePoolSize: this.config.iceCandidatePoolSize!,
chunkSize: this.config.chunkSize!,
maxRetries: this.config.maxRetries!,
retryDelay: this.config.retryDelay!,
ackTimeout: this.config.ackTimeout!,
onSignalingMessage: this.handleSignalingMessage.bind(this),
onTrack: this.handleTrack.bind(this),
});
this.dcManager = new DataChannelManager({
channelName: this.config.dataChannelName!,
onMessage: this.handleDataChannelMessage.bind(this),
onData: this.handleDataChannelData.bind(this),
});
this.messageRouter = new MessageRouter();
this.setupEventHandlers();
}
private setupEventHandlers(): void {
// WebSocket 事件处理
this.wsManager.on('connecting', (event) => {
this.updateState({ isConnecting: true, error: null });
});
this.wsManager.on('connected', (event) => {
this.updateState({
isWebSocketConnected: true,
isConnecting: false,
isConnected: true
});
});
this.wsManager.on('disconnected', (event: any) => {
this.updateState({ isWebSocketConnected: false });
if (!this.isUserDisconnecting) {
this.updateState({
error: event.reason,
canRetry: true
});
}
});
this.wsManager.on('error', (event: any) => {
this.updateState({
error: event.error.message,
canRetry: event.error.retryable
});
});
this.wsManager.on('message', (message: any) => {
this.handleWebSocketMessage(message);
});
// PeerConnection 事件处理
this.pcManager.on('state-change', (event: any) => {
this.updateState(event.state);
});
this.pcManager.on('error', (event: any) => {
this.updateState({
error: event.error.message,
canRetry: event.error.retryable
});
});
// DataChannel 事件处理
this.dcManager.on('state-change', (event: any) => {
this.updateState(event.state);
});
this.dcManager.on('error', (event: any) => {
this.updateState({
error: event.error.message,
canRetry: event.error.retryable
});
});
}
private updateState(updates: Partial<WebRTCConnectionState>): void {
this.state = { ...this.state, ...updates };
this.emit('state-change', { type: 'state-change', state: updates });
}
private handleWebSocketMessage(message: SignalingMessage): void {
if (this.config.enableLogging) {
console.log('[WebRTCManager] 收到信令消息:', message.type);
}
switch (message.type) {
case 'peer-joined':
this.handlePeerJoined(message.payload);
break;
case 'offer':
this.handleOffer(message.payload);
break;
case 'answer':
this.handleAnswer(message.payload);
break;
case 'ice-candidate':
this.handleIceCandidate(message.payload);
break;
case 'error':
this.handleError(message);
break;
case 'disconnection':
this.handleDisconnection(message);
break;
default:
if (this.config.enableLogging) {
console.warn('[WebRTCManager] 未知消息类型:', message.type);
}
}
}
private handleSignalingMessage(message: SignalingMessage): void {
this.wsManager.send(message);
}
private handleTrack(event: RTCTrackEvent): void {
if (this.config.enableLogging) {
console.log('[WebRTCManager] 收到媒体轨道:', event.track.kind);
}
// 这里可以添加轨道处理逻辑,或者通过事件传递给业务层
}
private handleDataChannelMessage(message: WebRTCMessage): void {
this.messageRouter.routeMessage(message);
}
private handleDataChannelData(data: ArrayBuffer): void {
// 默认路由到文件传输通道
this.messageRouter.routeData(data, 'file-transfer');
}
private async handlePeerJoined(payload: any): Promise<void> {
if (!this.currentRoom) return;
const { role } = payload;
const { role: currentRole } = this.currentRoom;
if (this.config.enableLogging) {
console.log('[WebRTCManager] 对方加入房间:', role);
}
if (currentRole === 'sender' && role === 'receiver') {
this.updateState({ isPeerConnected: true });
try {
await this.pcManager.createOffer();
} catch (error) {
console.error('[WebRTCManager] 创建Offer失败:', error);
}
} else if (currentRole === 'receiver' && role === 'sender') {
this.updateState({ isPeerConnected: true });
}
}
private async handleOffer(payload: RTCSessionDescriptionInit): Promise<void> {
try {
await this.pcManager.setRemoteDescription(payload);
const answer = await this.pcManager.createAnswer();
this.handleSignalingMessage({ type: 'answer', payload: answer });
} catch (error) {
console.error('[WebRTCManager] 处理Offer失败:', error);
this.updateState({
error: '处理连接请求失败',
canRetry: true
});
}
}
private async handleAnswer(payload: RTCSessionDescriptionInit): Promise<void> {
try {
await this.pcManager.setRemoteDescription(payload);
} catch (error) {
console.error('[WebRTCManager] 处理Answer失败:', error);
this.updateState({
error: '处理连接响应失败',
canRetry: true
});
}
}
private async handleIceCandidate(payload: RTCIceCandidateInit): Promise<void> {
try {
await this.pcManager.addIceCandidate(payload);
} catch (error) {
console.warn('[WebRTCManager] 添加ICE候选失败:', error);
}
}
private handleError(message: any): void {
this.updateState({
error: message.error || '信令服务器错误',
canRetry: true
});
}
private handleDisconnection(message: any): void {
this.updateState({
isPeerConnected: false,
error: '对方已离开房间',
canRetry: true
});
// 清理P2P连接但保持WebSocket连接
this.pcManager.destroyPeerConnection();
this.dcManager.close();
}
async connect(roomCode: string, role: 'sender' | 'receiver'): Promise<void> {
if (this.state.isConnecting) {
console.warn('[WebRTCManager] 正在连接中,跳过重复连接请求');
return;
}
this.isUserDisconnecting = false;
this.abortController = new AbortController();
try {
this.currentRoom = { code: roomCode, role };
this.updateState({
isConnecting: true,
error: null,
currentRoom: { code: roomCode, role }
});
// 创建PeerConnection
const pc = await this.pcManager.createPeerConnection();
// 如果是发送方,创建数据通道
if (role === 'sender') {
this.dcManager.createDataChannel(pc);
}
// 连接WebSocket
const baseWsUrl = getWsUrl();
if (!baseWsUrl) {
throw new WebRTCError('WS_URL_NOT_CONFIGURED', 'WebSocket URL未配置', false);
}
const wsUrl = baseWsUrl.replace('/ws/p2p', `/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`);
this.wsManager = new WebSocketManager({
url: wsUrl,
reconnectAttempts: 5,
reconnectDelay: 1000,
timeout: 10000,
});
this.setupEventHandlers();
await this.wsManager.connect();
} catch (error) {
console.error('[WebRTCManager] 连接失败:', error);
this.updateState({
error: error instanceof WebRTCError ? error.message : '连接失败',
isConnecting: false,
canRetry: true
});
throw error;
}
}
disconnect(): void {
if (this.config.enableLogging) {
console.log('[WebRTCManager] 主动断开连接');
}
this.isUserDisconnecting = true;
this.abortController.abort();
// 通知对方断开连接
this.wsManager.send({
type: 'disconnection',
payload: { reason: '用户主动断开' }
});
// 清理所有连接
this.dcManager.close();
this.pcManager.destroyPeerConnection();
this.wsManager.disconnect();
this.messageRouter.clear();
// 重置状态
this.currentRoom = null;
this.updateState({
isConnected: false,
isConnecting: false,
isWebSocketConnected: false,
isPeerConnected: false,
error: null,
canRetry: false,
currentRoom: null,
});
}
async retry(): Promise<void> {
if (!this.currentRoom) {
throw new WebRTCError('NO_ROOM_INFO', '没有房间信息,无法重试', false);
}
if (this.config.enableLogging) {
console.log('[WebRTCManager] 重试连接:', this.currentRoom);
}
this.disconnect();
await this.connect(this.currentRoom.code, this.currentRoom.role);
}
sendMessage(message: WebRTCMessage, channel?: string): boolean {
const messageWithChannel = channel ? { ...message, channel } : message;
return this.dcManager.sendMessage(messageWithChannel);
}
sendData(data: ArrayBuffer): boolean {
return this.dcManager.sendData(data);
}
registerMessageHandler(channel: string, handler: MessageHandler): () => void {
return this.messageRouter.registerMessageHandler(channel, handler);
}
registerDataHandler(channel: string, handler: DataHandler): () => void {
return this.messageRouter.registerDataHandler(channel, handler);
}
addTrack(track: MediaStreamTrack, stream: MediaStream): RTCRtpSender | null {
return this.pcManager.addTrack(track, stream);
}
removeTrack(sender: RTCRtpSender): void {
this.pcManager.removeTrack(sender);
}
onTrack(handler: (event: RTCTrackEvent) => void): void {
// 简化实现,直接设置处理器
this.pcManager.on('track', (event) => {
if (event.type === 'state-change' && 'onTrack' in this.config) {
// 这里需要适配事件类型
}
});
}
getPeerConnection(): RTCPeerConnection | null {
// 返回内部 PeerConnection 的引用
return (this.pcManager as any).pc;
}
async createOfferNow(): Promise<boolean> {
try {
await this.pcManager.createOffer();
return true;
} catch (error) {
console.error('[WebRTCManager] 创建Offer失败:', error);
return false;
}
}
getChannelState(): RTCDataChannelState {
return this.dcManager.getState();
}
isConnectedToRoom(roomCode: string, role: 'sender' | 'receiver'): boolean {
return this.currentRoom?.code === roomCode &&
this.currentRoom?.role === role &&
this.state.isConnected;
}
getState(): WebRTCConnectionState {
return { ...this.state };
}
getConfig(): WebRTCManagerConfig {
return { ...this.config };
}
}

View File

@@ -1,218 +0,0 @@
import { EventEmitter } from 'events';
import { WebRTCError, WebRTCMessage, ConnectionEvent, EventHandler } from './types';
interface WebSocketManagerConfig {
url: string;
reconnectAttempts?: number;
reconnectDelay?: number;
timeout?: number;
}
interface WebSocketMessage {
type: string;
payload: any;
}
export class WebSocketManager extends EventEmitter {
private ws: WebSocket | null = null;
private config: WebSocketManagerConfig;
private reconnectCount = 0;
private isConnecting = false;
private isUserDisconnecting = false;
private messageQueue: WebSocketMessage[] = [];
private reconnectTimer: NodeJS.Timeout | null = null;
constructor(config: WebSocketManagerConfig) {
super();
this.config = {
reconnectAttempts: 5,
reconnectDelay: 1000,
timeout: 10000,
...config
};
}
async connect(): Promise<void> {
if (this.isConnecting || this.isConnected()) {
return;
}
this.isConnecting = true;
this.isUserDisconnecting = false;
this.emit('connecting', { type: 'connecting' });
try {
const ws = new WebSocket(this.config.url);
// 设置超时
const timeout = setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING) {
ws.close();
throw new WebRTCError('WS_TIMEOUT', 'WebSocket连接超时', true);
}
}, this.config.timeout);
await new Promise<void>((resolve, reject) => {
ws.onopen = () => {
clearTimeout(timeout);
this.isConnecting = false;
this.reconnectCount = 0;
this.ws = ws;
this.setupEventHandlers();
this.flushMessageQueue();
this.emit('connected', { type: 'connected' });
resolve();
};
ws.onerror = (errorEvent) => {
clearTimeout(timeout);
this.isConnecting = false;
const wsError = new WebRTCError('WS_ERROR', 'WebSocket连接错误', true, new Error('WebSocket连接错误'));
this.emit('error', { type: 'error', error: wsError });
reject(wsError);
};
ws.onclose = (closeEvent) => {
clearTimeout(timeout);
this.isConnecting = false;
this.ws = null;
if (!this.isUserDisconnecting) {
this.handleReconnect();
}
this.emit('disconnected', {
type: 'disconnected',
reason: `WebSocket关闭: ${closeEvent.code} - ${closeEvent.reason}`
});
};
});
} catch (error) {
this.isConnecting = false;
if (error instanceof WebRTCError) {
this.emit('error', { type: 'error', error });
throw error;
}
throw new WebRTCError('WS_CONNECTION_FAILED', 'WebSocket连接失败', true, error as Error);
}
}
private setupEventHandlers(): void {
if (!this.ws) return;
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.emit('message', message);
} catch (error) {
console.error('解析WebSocket消息失败:', error);
this.emit('error', {
type: 'error',
error: new WebRTCError('WS_MESSAGE_PARSE_ERROR', '消息解析失败', false, error as Error)
});
}
};
this.ws.onerror = (errorEvent) => {
this.emit('error', {
type: 'error',
error: new WebRTCError('WS_ERROR', 'WebSocket错误', true, new Error('WebSocket错误'))
});
};
this.ws.onclose = (event) => {
this.ws = null;
if (!this.isUserDisconnecting) {
this.handleReconnect();
}
this.emit('disconnected', {
type: 'disconnected',
reason: `WebSocket关闭: ${event.code} - ${event.reason}`
});
};
}
private handleReconnect(): void {
if (this.reconnectCount >= this.config.reconnectAttempts!) {
this.emit('error', {
type: 'error',
error: new WebRTCError('WS_RECONNECT_FAILED', '重连失败,已达最大重试次数', false)
});
return;
}
const delay = this.config.reconnectDelay! * Math.pow(2, this.reconnectCount);
this.reconnectCount++;
console.log(`WebSocket重连中... (${this.reconnectCount}/${this.config.reconnectAttempts}),延迟: ${delay}ms`);
this.reconnectTimer = setTimeout(() => {
this.emit('retry', { type: 'retry' });
this.connect().catch(error => {
console.error('WebSocket重连失败:', error);
});
}, delay);
}
send(message: WebSocketMessage): boolean {
if (!this.isConnected()) {
this.messageQueue.push(message);
return false;
}
try {
this.ws!.send(JSON.stringify(message));
return true;
} catch (error) {
console.error('发送WebSocket消息失败:', error);
this.emit('error', {
type: 'error',
error: new WebRTCError('WS_SEND_ERROR', '发送消息失败', true, error as Error)
});
return false;
}
}
private flushMessageQueue(): void {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
if (message) {
this.send(message);
}
}
}
disconnect(): void {
this.isUserDisconnecting = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close(1000, '用户主动断开');
this.ws = null;
}
this.messageQueue = [];
this.isConnecting = false;
}
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
isConnectingState(): boolean {
return this.isConnecting;
}
on(event: string, handler: EventHandler<ConnectionEvent>): this {
return super.on(event, handler);
}
emit(eventName: string, event?: ConnectionEvent): boolean {
return super.emit(eventName, event);
}
}

View File

@@ -1,7 +0,0 @@
// WebRTC核心功能
export * from './DataChannelManager';
export * from './MessageRouter';
export * from './PeerConnectionManager';
export * from './WebRTCManager';
export * from './WebSocketManager';
export * from './types';

View File

@@ -1,58 +0,0 @@
// WebRTC 核心类型定义
// 基础连接状态
export interface WebRTCConnectionState {
isConnected: boolean;
isConnecting: boolean;
isWebSocketConnected: boolean;
isPeerConnected: boolean;
error: string | null;
canRetry: boolean;
currentRoom: { code: string; role: 'sender' | 'receiver' } | null;
}
// 消息类型
export interface WebRTCMessage<T = any> {
type: string;
payload: T;
channel?: string;
}
// 消息处理器类型
export type MessageHandler = (message: WebRTCMessage) => void;
export type DataHandler = (data: ArrayBuffer) => void;
// WebRTC 配置
export interface WebRTCConfig {
iceServers: RTCIceServer[];
iceCandidatePoolSize: number;
chunkSize: number;
maxRetries: number;
retryDelay: number;
ackTimeout: number;
}
// 错误类型
export class WebRTCError extends Error {
constructor(
public code: string,
message: string,
public retryable: boolean = false,
public cause?: Error
) {
super(message);
this.name = 'WebRTCError';
}
}
// 连接事件
export type ConnectionEvent =
| { type: 'connecting' }
| { type: 'connected' }
| { type: 'disconnected'; reason?: string }
| { type: 'error'; error: WebRTCError }
| { type: 'retry' }
| { type: 'state-change'; state: Partial<WebRTCConnectionState> };
// 事件处理器
export type EventHandler<T extends ConnectionEvent> = (event: T) => void;

View File

@@ -106,13 +106,6 @@ export class ClientAPI {
async getRoomInfo(code: string): Promise<ApiResponse> {
return this.get(`/api/room-info?code=${code}`);
}
/**
* 获取WebRTC房间状态
*/
async getWebRTCRoomStatus(code: string): Promise<ApiResponse> {
return this.get(`/api/webrtc-room-status?code=${code}`);
}
}
// 导出单例实例

View File

@@ -26,19 +26,20 @@ const getCurrentBaseUrl = () => {
// 动态获取 WebSocket URL - 总是在客户端运行时计算
const getCurrentWsUrl = () => {
// return "ws://192.168.1.120:8080"
if (typeof window !== 'undefined') {
// 检查是否是 Next.js 开发服务器(端口 3000 或 3001
const isNextDevServer = window.location.hostname === 'localhost' &&
(window.location.port === '3000' || window.location.port === '3001');
const isNextDevServer = window.location.hostname === 'localhost' &&
(window.location.port === '3000' || window.location.port === '3001');
if (isNextDevServer) {
// 开发模式:通过 Next.js 开发服务器访问,连接到后端 WebSocket
return 'ws://localhost:8080/ws/p2p';
return 'ws://localhost:8080';
}
// 生产模式或通过 Go 服务器访问:使用当前域名和端口
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws/p2p`;
return `${protocol}//${window.location.host}`;
}
// 服务器端返回空字符串,强制在客户端计算
return '';
@@ -49,28 +50,28 @@ export const config = {
isDev: getEnv('NODE_ENV') === 'development',
isProd: getEnv('NODE_ENV') === 'production',
isStatic: typeof window !== 'undefined', // 客户端运行时认为是静态模式
// API配置
api: {
// 后端API地址 (服务器端使用)
backendUrl: getEnv('GO_BACKEND_URL', 'http://localhost:8080'),
// 前端API基础URL (客户端使用) - 开发模式下调用 Next.js API 路由
baseUrl: getEnv('NEXT_PUBLIC_API_BASE_URL', 'http://localhost:3000'),
// 直接后端URL (客户端在静态模式下使用) - 如果环境变量为空,则使用当前域名
directBackendUrl: getEnv('NEXT_PUBLIC_BACKEND_URL') || getCurrentBaseUrl(),
// WebSocket地址 - 在客户端运行时动态计算,不在构建时预设
wsUrl: '', // 将通过 getWsUrl() 函数动态获取
},
// 超时配置
timeout: {
api: 30000, // 30秒
ws: 60000, // 60秒
},
// 重试配置
retry: {
max: 3,
@@ -122,12 +123,12 @@ export function getWsUrl(): string {
if (envWsUrl) {
return envWsUrl;
}
// 如果是服务器端SSG构建时返回空字符串
if (typeof window === 'undefined') {
return '';
}
// 客户端运行时动态计算
return getCurrentWsUrl();
}

View File

@@ -0,0 +1,273 @@
/**
* 传输速度和时间计算工具
*/
export interface TransferSpeed {
bytesPerSecond: number;
displaySpeed: string;
unit: string;
}
export interface TransferProgress {
totalBytes: number;
transferredBytes: number;
percentage: number;
speed: TransferSpeed;
remainingTime: {
seconds: number;
display: string;
};
elapsedTime: {
seconds: number;
display: string;
};
}
/**
* 格式化传输速度显示
* @param bytesPerSecond 每秒传输的字节数
* @returns 格式化的速度显示
*/
export function formatTransferSpeed(bytesPerSecond: number): TransferSpeed {
if (bytesPerSecond < 1024) {
return {
bytesPerSecond,
displaySpeed: `${bytesPerSecond.toFixed(0)}`,
unit: 'B/s'
};
} else if (bytesPerSecond < 1024 * 1024) {
const kbps = bytesPerSecond / 1024;
return {
bytesPerSecond,
displaySpeed: `${kbps.toFixed(1)}`,
unit: 'KB/s'
};
} else {
const mbps = bytesPerSecond / (1024 * 1024);
return {
bytesPerSecond,
displaySpeed: `${mbps.toFixed(1)}`,
unit: 'MB/s'
};
}
}
/**
* 格式化时间显示
* @param seconds 秒数
* @returns 格式化的时间显示
*/
export function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) {
return '--:--';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
}
/**
* 传输进度跟踪器
*/
export class TransferProgressTracker {
private startTime: number;
private lastUpdateTime: number;
private lastSpeedUpdateTime: number;
private lastProgressUpdateTime: number;
private lastTransferredBytes: number;
private speedHistory: number[] = [];
private readonly maxHistorySize = 10; // 保持最近10个速度样本
private readonly speedUpdateInterval = 300; // 速度更新间隔0.3秒
private readonly progressUpdateInterval = 50; // 进度更新间隔0.3秒
private cachedProgress: TransferProgress | null = null;
private lastDisplayedSpeed: TransferSpeed;
private lastDisplayedPercentage: number = 0;
constructor(
private totalBytes: number,
private initialTransferredBytes: number = 0
) {
this.startTime = Date.now();
this.lastUpdateTime = this.startTime;
this.lastSpeedUpdateTime = this.startTime;
this.lastProgressUpdateTime = this.startTime;
this.lastTransferredBytes = initialTransferredBytes;
this.lastDisplayedSpeed = formatTransferSpeed(0);
}
/**
* 更新传输进度
* @param transferredBytes 已传输的字节数
* @returns 传输进度信息
*/
update(transferredBytes: number): TransferProgress {
const now = Date.now();
const elapsedTimeMs = now - this.startTime;
const timeSinceLastUpdate = now - this.lastUpdateTime;
const timeSinceLastSpeedUpdate = now - this.lastSpeedUpdateTime;
const timeSinceLastProgressUpdate = now - this.lastProgressUpdateTime;
// 计算即时速度(基于最近的更新)
let instantSpeed = 0;
if (timeSinceLastUpdate > 0) {
const bytesDiff = transferredBytes - this.lastTransferredBytes;
instantSpeed = (bytesDiff * 1000) / timeSinceLastUpdate; // bytes per second
}
// 只有当距离上次速度更新超过指定间隔时才更新速度显示
let shouldUpdateSpeed = timeSinceLastSpeedUpdate >= this.speedUpdateInterval;
// 只有当距离上次进度更新超过指定间隔时才更新进度显示
let shouldUpdateProgress = timeSinceLastProgressUpdate >= this.progressUpdateInterval;
// 如果是第一次更新或者传输完成,立即更新速度和进度
if (this.cachedProgress === null || transferredBytes >= this.totalBytes) {
shouldUpdateSpeed = true;
shouldUpdateProgress = true;
}
if (shouldUpdateSpeed) {
// 更新速度历史
if (instantSpeed > 0) {
this.speedHistory.push(instantSpeed);
if (this.speedHistory.length > this.maxHistorySize) {
this.speedHistory.shift();
}
}
// 计算平均速度
let averageSpeed = 0;
if (this.speedHistory.length > 0) {
averageSpeed = this.speedHistory.reduce((sum, speed) => sum + speed, 0) / this.speedHistory.length;
} else if (elapsedTimeMs > 0) {
// 如果没有即时速度历史,使用总体平均速度
averageSpeed = (transferredBytes * 1000) / elapsedTimeMs;
}
// 更新显示的速度
this.lastDisplayedSpeed = formatTransferSpeed(averageSpeed);
this.lastSpeedUpdateTime = now;
}
// 更新显示的进度百分比
if (shouldUpdateProgress) {
const currentPercentage = this.totalBytes > 0 ? (transferredBytes / this.totalBytes) * 100 : 0;
this.lastDisplayedPercentage = Math.min(currentPercentage, 100);
this.lastProgressUpdateTime = now;
}
// 计算剩余时间(使用当前显示的速度)
const remainingBytes = this.totalBytes - transferredBytes;
const remainingTimeSeconds = this.lastDisplayedSpeed.bytesPerSecond > 0
? remainingBytes / this.lastDisplayedSpeed.bytesPerSecond
: Infinity;
// 更新跟踪状态
this.lastUpdateTime = now;
this.lastTransferredBytes = transferredBytes;
// 创建进度对象(使用稳定的进度值)
const progress: TransferProgress = {
totalBytes: this.totalBytes,
transferredBytes,
percentage: this.lastDisplayedPercentage,
speed: this.lastDisplayedSpeed,
remainingTime: {
seconds: remainingTimeSeconds,
display: formatTime(remainingTimeSeconds)
},
elapsedTime: {
seconds: elapsedTimeMs / 1000,
display: formatTime(elapsedTimeMs / 1000)
}
};
// 缓存进度信息
this.cachedProgress = progress;
return progress;
}
/**
* 重置跟踪器
*/
reset(totalBytes?: number, initialTransferredBytes: number = 0) {
if (totalBytes !== undefined) {
this.totalBytes = totalBytes;
}
this.startTime = Date.now();
this.lastUpdateTime = this.startTime;
this.lastSpeedUpdateTime = this.startTime;
this.lastProgressUpdateTime = this.startTime;
this.lastTransferredBytes = initialTransferredBytes;
this.speedHistory = [];
this.cachedProgress = null;
this.lastDisplayedSpeed = formatTransferSpeed(0);
this.lastDisplayedPercentage = 0;
}
/**
* 获取总字节数
*/
getTotalBytes(): number {
return this.totalBytes;
}
/**
* 获取平均速度(整个传输过程)
*/
getOverallAverageSpeed(): number {
const elapsedTimeMs = Date.now() - this.startTime;
if (elapsedTimeMs > 0) {
return (this.lastTransferredBytes * 1000) / elapsedTimeMs;
}
return 0;
}
}
/**
* 创建传输进度跟踪器
* @param totalBytes 总字节数
* @param initialTransferredBytes 初始已传输字节数
* @returns 传输进度跟踪器实例
*/
export function createTransferTracker(totalBytes: number, initialTransferredBytes: number = 0): TransferProgressTracker {
return new TransferProgressTracker(totalBytes, initialTransferredBytes);
}
/**
* 简单的传输速度计算(无状态)
* @param transferredBytes 已传输字节数
* @param elapsedTimeMs 经过的时间(毫秒)
* @returns 格式化的速度
*/
export function calculateSpeed(transferredBytes: number, elapsedTimeMs: number): TransferSpeed {
if (elapsedTimeMs <= 0) {
return formatTransferSpeed(0);
}
const bytesPerSecond = (transferredBytes * 1000) / elapsedTimeMs;
return formatTransferSpeed(bytesPerSecond);
}
/**
* 计算剩余时间
* @param remainingBytes 剩余字节数
* @param bytesPerSecond 每秒传输字节数
* @returns 格式化的剩余时间
*/
export function calculateRemainingTime(remainingBytes: number, bytesPerSecond: number): string {
if (bytesPerSecond <= 0 || remainingBytes <= 0) {
return '--:--';
}
const remainingSeconds = remainingBytes / bytesPerSecond;
return formatTime(remainingSeconds);
}

View File

@@ -43,6 +43,7 @@ export function detectWebRTCSupport(): WebRTCSupport {
pc.close();
}
} catch (error) {
console.warn(error);
missing.push('DataChannel');
}

178
cmd/config.go Normal file
View File

@@ -0,0 +1,178 @@
package main
import (
"bufio"
"flag"
"fmt"
"log"
"os"
"strconv"
"strings"
)
// Config 应用配置结构
type Config struct {
Port int
FrontendDir string
TurnConfig TurnConfig
}
// TurnConfig TURN服务器配置
type TurnConfig struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Realm string `json:"realm"`
}
// loadEnvFile 加载环境变量文件
func loadEnvFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// 跳过空行和注释行
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// 解析 KEY=VALUE 格式
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// 移除值两端的引号
if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
(strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
value = value[1 : len(value)-1]
}
// 只有当环境变量不存在时才设置
if os.Getenv(key) == "" {
os.Setenv(key, value)
}
}
}
return scanner.Err()
}
// showHelp 显示帮助信息
func showHelp() {
fmt.Println("文件传输服务器")
fmt.Println("用法:")
fmt.Println(" 配置文件:")
fmt.Println(" .chuan.env - 自动加载的配置文件")
fmt.Println(" 环境变量:")
fmt.Println(" PORT=8080 - 服务器监听端口")
fmt.Println(" FRONTEND_DIR=/path - 外部前端文件目录 (可选)")
fmt.Println(" TURN_ENABLED=true - 启用TURN服务器")
fmt.Println(" TURN_PORT=3478 - TURN服务器端口")
fmt.Println(" TURN_USERNAME=user - TURN服务器用户名")
fmt.Println(" TURN_PASSWORD=pass - TURN服务器密码")
fmt.Println(" TURN_REALM=localhost - TURN服务器域")
fmt.Println(" 命令行参数:")
flag.PrintDefaults()
fmt.Println("")
fmt.Println("配置优先级: 命令行参数 > 环境变量 > 配置文件 > 默认值")
fmt.Println("")
fmt.Println("示例:")
fmt.Println(" ./file-transfer-server")
fmt.Println(" ./file-transfer-server -port 3000")
fmt.Println(" PORT=8080 FRONTEND_DIR=./dist ./file-transfer-server")
fmt.Println(" TURN_ENABLED=true TURN_PORT=3478 ./file-transfer-server")
}
// loadConfig 加载应用配置
func loadConfig() *Config {
// 首先尝试加载 .chuan.env 文件
if err := loadEnvFile(".chuan.env"); err == nil {
log.Printf("📄 已加载配置文件: .chuan.env")
}
// 从环境变量获取配置,如果没有则使用默认值
defaultPort := 8080
if envPort := os.Getenv("PORT"); envPort != "" {
if port, err := strconv.Atoi(envPort); err == nil {
defaultPort = port
}
}
// TURN 配置默认值
turnEnabled := os.Getenv("TURN_ENABLED") == "true"
turnPort := 3478
if envTurnPort := os.Getenv("TURN_PORT"); envTurnPort != "" {
if port, err := strconv.Atoi(envTurnPort); err == nil {
turnPort = port
}
}
turnUsername := os.Getenv("TURN_USERNAME")
if turnUsername == "" {
turnUsername = "chuan"
}
turnPassword := os.Getenv("TURN_PASSWORD")
if turnPassword == "" {
turnPassword = "chuan123"
}
turnRealm := os.Getenv("TURN_REALM")
if turnRealm == "" {
turnRealm = "localhost"
}
// 定义命令行参数
var port = flag.Int("port", defaultPort, "服务器监听端口 (可通过 PORT 环境变量设置)")
var help = flag.Bool("help", false, "显示帮助信息")
flag.Parse()
// 显示帮助信息
if *help {
showHelp()
os.Exit(0)
}
config := &Config{
Port: *port,
FrontendDir: os.Getenv("FRONTEND_DIR"),
TurnConfig: TurnConfig{
Enabled: turnEnabled,
Port: turnPort,
Username: turnUsername,
Password: turnPassword,
Realm: turnRealm,
},
}
return config
}
// logConfig 记录配置信息
func logConfig(config *Config) {
// 记录前端配置信息
if config.FrontendDir != "" {
if info, err := os.Stat(config.FrontendDir); err == nil && info.IsDir() {
log.Printf("✅ 使用外部前端目录: %s", config.FrontendDir)
} else {
log.Printf("⚠️ 外部前端目录不可用: %s, 回退到内嵌文件", config.FrontendDir)
}
} else {
log.Printf("📦 使用内嵌前端文件")
}
// 记录 TURN 配置信息
if config.TurnConfig.Enabled {
log.Printf("🔄 TURN服务器已启用")
log.Printf(" 端口: %d", config.TurnConfig.Port)
log.Printf(" 用户名: %s", config.TurnConfig.Username)
log.Printf(" 域: %s", config.TurnConfig.Realm)
} else {
log.Printf("❌ TURN服务器已禁用")
}
}

View File

@@ -1,103 +1,25 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"chuan/internal/handlers"
"chuan/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
func main() {
// 定义命令行参数
var port = flag.Int("port", 8080, "服务器监听端口")
var help = flag.Bool("help", false, "显示帮助信息")
flag.Parse()
// 显示帮助信息
if *help {
fmt.Println("文件传输服务器")
fmt.Println("用法:")
flag.PrintDefaults()
os.Exit(0)
// 检查是否需要显示帮助
if len(os.Args) > 1 && (os.Args[1] == "-h" || os.Args[1] == "--help") {
showHelp()
return
}
// 初始化处理器
h := handlers.NewHandler()
// 加载配置
config := loadConfig()
// 创建路由
r := chi.NewRouter()
// 记录配置信息
logConfig(config)
// 中间件
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Compress(5))
// 设置路由
routerSetup := setupRouter(config)
// CORS 配置
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
// 嵌入式前端文件服务
r.Handle("/*", web.CreateFrontendHandler())
// WebRTC信令WebSocket路由
r.Get("/ws/webrtc", h.HandleWebRTCWebSocket)
// WebRTC房间API
r.Post("/api/create-room", h.CreateRoomHandler)
r.Get("/api/room-info", h.WebRTCRoomStatusHandler)
r.Get("/api/webrtc-room-status", h.WebRTCRoomStatusHandler)
// 构建服务器地址
addr := fmt.Sprintf(":%d", *port)
// 启动服务器
srv := &http.Server{
Addr: addr,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 优雅关闭
go func() {
log.Printf("服务器启动在端口 %s", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务器启动失败: %v", err)
}
}()
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("正在关闭服务器...")
// 设置关闭超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("服务器强制关闭:", err)
}
log.Println("服务器已退出")
// 运行服务器(包含启动和优雅关闭)
RunServer(config, routerSetup)
}

76
cmd/router.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"net/http"
"chuan/internal/handlers"
"chuan/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
// RouterSetup 路由设置结果
type RouterSetup struct {
Handler *handlers.Handler
Router http.Handler
}
// setupRouter 设置路由和中间件
func setupRouter(config *Config) *RouterSetup {
// 初始化处理器
h := handlers.NewHandler()
router := chi.NewRouter()
// 设置中间件
setupMiddleware(router)
// 设置API路由
setupAPIRoutes(router, h, config)
// 设置前端路由
router.Handle("/*", web.CreateFrontendHandler())
return &RouterSetup{
Handler: h,
Router: router,
}
}
// setupMiddleware 设置中间件
func setupMiddleware(r *chi.Mux) {
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Compress(5))
// CORS 配置
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
}
// setupAPIRoutes 设置API路由
func setupAPIRoutes(r *chi.Mux, h *handlers.Handler, config *Config) {
// WebRTC信令WebSocket路由
r.Get("/api/ws/webrtc", h.HandleWebRTCWebSocket)
// WebRTC房间API
r.Post("/api/create-room", h.CreateRoomHandler)
r.Get("/api/room-info", h.WebRTCRoomStatusHandler)
// TURN服务器API仅在启用时可用
if config.TurnConfig.Enabled {
r.Get("/api/turn/stats", h.TurnStatsHandler)
r.Get("/api/turn/config", h.TurnConfigHandler)
}
// 管理API
r.Get("/api/admin/status", h.AdminStatusHandler)
}

111
cmd/server.go Normal file
View File

@@ -0,0 +1,111 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"chuan/internal/services"
)
// Server 服务器结构
type Server struct {
httpServer *http.Server
config *Config
turnService *services.TurnService
}
// NewServer 创建新的服务器实例
func NewServer(config *Config, routerSetup *RouterSetup) *Server {
server := &Server{
httpServer: &http.Server{
Addr: fmt.Sprintf(":%d", config.Port),
Handler: routerSetup.Router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
},
config: config,
}
// 如果启用了TURN服务器创建TURN服务实例
if config.TurnConfig.Enabled {
turnConfig := services.TurnServiceConfig{
Port: config.TurnConfig.Port,
Username: config.TurnConfig.Username,
Password: config.TurnConfig.Password,
Realm: config.TurnConfig.Realm,
}
server.turnService = services.NewTurnService(turnConfig)
// 将TURN服务设置到处理器中
routerSetup.Handler.SetTurnService(server.turnService)
}
return server
}
// Start 启动服务器
func (s *Server) Start() error {
// 启动TURN服务器如果启用
if s.turnService != nil {
if err := s.turnService.Start(); err != nil {
return fmt.Errorf("启动TURN服务器失败: %v", err)
}
}
log.Printf("🚀 服务器启动在端口 :%d", s.config.Port)
return s.httpServer.ListenAndServe()
}
// Stop 停止服务器
func (s *Server) Stop(ctx context.Context) error {
log.Println("🛑 正在关闭服务器...")
// 停止TURN服务器如果启用
if s.turnService != nil {
if err := s.turnService.Stop(); err != nil {
log.Printf("⚠️ 停止TURN服务器失败: %v", err)
}
}
return s.httpServer.Shutdown(ctx)
}
// WaitForShutdown 等待关闭信号并优雅关闭
func (s *Server) WaitForShutdown() {
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// 设置关闭超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.Stop(ctx); err != nil {
log.Fatal("❌ 服务器强制关闭:", err)
}
log.Println("✅ 服务器已退出")
}
// RunServer 运行服务器(包含启动和优雅关闭)
func RunServer(config *Config, routerSetup *RouterSetup) {
server := NewServer(config, routerSetup)
// 启动服务器
go func() {
if err := server.Start(); err != nil && err != http.ErrServerClosed {
log.Fatalf("❌ 服务器启动失败: %v", err)
}
}()
// 等待关闭信号
server.WaitForShutdown()
}

13
go.mod
View File

@@ -5,6 +5,17 @@ go 1.21
require (
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/cors v1.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
)
require (
github.com/pion/dtls/v2 v2.2.7 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/stun/v2 v2.0.0 // indirect
github.com/pion/transport/v2 v2.2.1 // indirect
github.com/pion/transport/v3 v3.0.2 // indirect
github.com/pion/turn/v3 v3.0.3 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/sys v0.18.0 // indirect
)

86
go.sum
View File

@@ -1,8 +1,90 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4=
github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0=
github.com/pion/turn/v3 v3.0.3 h1:1e3GVk8gHZLPBA5LqadWYV60lmaKUaHCkm9DX9CkGcE=
github.com/pion/turn/v3 v3.0.3/go.mod h1:vw0Dz420q7VYAF3J4wJKzReLHIo2LGp4ev8nXQexYsc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -10,6 +10,7 @@ import (
type Handler struct {
webrtcService *services.WebRTCService
turnService *services.TurnService
}
func NewHandler() *Handler {
@@ -18,6 +19,11 @@ func NewHandler() *Handler {
}
}
// SetTurnService 设置TURN服务实例
func (h *Handler) SetTurnService(turnService *services.TurnService) {
h.turnService = turnService
}
// HandleWebRTCWebSocket 处理WebRTC信令WebSocket连接
func (h *Handler) HandleWebRTCWebSocket(w http.ResponseWriter, r *http.Request) {
h.webrtcService.HandleWebSocket(w, r)
@@ -105,3 +111,101 @@ func (h *Handler) GetRoomStatusHandler(w http.ResponseWriter, r *http.Request) {
status := h.webrtcService.GetRoomStatus(code)
json.NewEncoder(w).Encode(status)
}
// TurnStatsHandler 获取TURN服务器统计信息API
func (h *Handler) TurnStatsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "方法不允许",
})
return
}
if h.turnService == nil {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "TURN服务器未启用",
})
return
}
stats := h.turnService.GetStats()
response := map[string]interface{}{
"success": true,
"data": stats,
}
json.NewEncoder(w).Encode(response)
}
// TurnConfigHandler 获取TURN服务器配置信息API用于前端WebRTC配置
func (h *Handler) TurnConfigHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "方法不允许",
})
return
}
if h.turnService == nil || !h.turnService.IsRunning() {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "TURN服务器未启用或未运行",
})
return
}
turnInfo := h.turnService.GetTurnServerInfo()
response := map[string]interface{}{
"success": true,
"data": turnInfo,
}
json.NewEncoder(w).Encode(response)
}
// AdminStatusHandler 获取服务器总体状态API
func (h *Handler) AdminStatusHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method != http.MethodGet {
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "方法不允许",
})
return
}
// 获取WebRTC服务状态
// 这里简化实际可以从WebRTC服务获取更多信息
webrtcStatus := map[string]interface{}{
"isRunning": true, // WebRTC服务总是运行的
}
// 获取TURN服务状态
var turnStatus interface{}
if h.turnService != nil {
turnStatus = h.turnService.GetStats()
} else {
turnStatus = map[string]interface{}{
"isRunning": false,
"message": "TURN服务器未启用",
}
}
response := map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"webrtc": webrtcStatus,
"turn": turnStatus,
},
}
json.NewEncoder(w).Encode(response)
}

View File

@@ -1,56 +0,0 @@
package models
import (
"time"
"github.com/gorilla/websocket"
)
// WebRTCOffer WebRTC offer 结构
type WebRTCOffer struct {
SDP string `json:"sdp"`
Type string `json:"type"`
}
// WebRTCAnswer WebRTC answer 结构
type WebRTCAnswer struct {
SDP string `json:"sdp"`
Type string `json:"type"`
}
// WebRTCICECandidate ICE candidate 结构
type WebRTCICECandidate struct {
Candidate string `json:"candidate"`
SDPMLineIndex int `json:"sdpMLineIndex"`
SDPMid string `json:"sdpMid"`
}
// VideoMessage 视频消息结构
type VideoMessage struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
// ClientInfo 客户端连接信息
type ClientInfo struct {
ID string `json:"id"` // 客户端唯一标识
Role string `json:"role"` // sender 或 receiver
Connection *websocket.Conn `json:"-"` // WebSocket连接不序列化
JoinedAt time.Time `json:"joined_at"` // 加入时间
UserAgent string `json:"user_agent"` // 用户代理
}
// RoomStatus 房间状态信息
type RoomStatus struct {
Code string `json:"code"`
SenderOnline bool `json:"sender_online"`
ReceiverOnline bool `json:"receiver_online"`
CreatedAt time.Time `json:"created_at"`
}
// ErrorResponse 错误响应结构
type ErrorResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Code string `json:"code,omitempty"`
}

View File

@@ -0,0 +1,234 @@
package services
import (
"fmt"
"log"
"net"
"sync"
"github.com/pion/turn/v3"
)
// TurnService TURN服务器结构
type TurnService struct {
server *turn.Server
config TurnServiceConfig
stats *TurnStats
isRunning bool
mu sync.RWMutex
}
// TurnServiceConfig TURN服务器配置
type TurnServiceConfig struct {
Port int
Username string
Password string
Realm string
}
// TurnStats TURN服务器统计信息
type TurnStats struct {
ActiveAllocations int64
TotalAllocations int64
BytesTransferred int64
PacketsTransferred int64
Connections int64
mu sync.RWMutex
}
// NewTurnService 创建新的TURN服务实例
func NewTurnService(config TurnServiceConfig) *TurnService {
return &TurnService{
config: config,
stats: &TurnStats{},
}
}
// Start 启动TURN服务器
func (ts *TurnService) Start() error {
ts.mu.Lock()
defer ts.mu.Unlock()
if ts.isRunning {
return fmt.Errorf("TURN服务器已在运行")
}
// 监听UDP端口
udpListener, err := net.ListenPacket("udp4", fmt.Sprintf("0.0.0.0:%d", ts.config.Port))
if err != nil {
return fmt.Errorf("无法监听UDP端口: %v", err)
}
// 监听TCP端口
tcpListener, err := net.Listen("tcp4", fmt.Sprintf("0.0.0.0:%d", ts.config.Port))
if err != nil {
udpListener.Close()
return fmt.Errorf("无法监听TCP端口: %v", err)
}
// 创建TURN服务器配置
turnConfig := turn.ServerConfig{
Realm: ts.config.Realm,
AuthHandler: ts.authHandler,
PacketConnConfigs: []turn.PacketConnConfig{
{
PacketConn: udpListener,
RelayAddressGenerator: &turn.RelayAddressGeneratorStatic{
RelayAddress: net.ParseIP("127.0.0.1"), // 在生产环境中应该使用公网IP
Address: "0.0.0.0",
},
},
},
ListenerConfigs: []turn.ListenerConfig{
{
Listener: tcpListener,
RelayAddressGenerator: &turn.RelayAddressGeneratorStatic{
RelayAddress: net.ParseIP("127.0.0.1"), // 在生产环境中应该使用公网IP
Address: "0.0.0.0",
},
},
},
}
// 创建TURN服务器
server, err := turn.NewServer(turnConfig)
if err != nil {
udpListener.Close()
tcpListener.Close()
return fmt.Errorf("创建TURN服务器失败: %v", err)
}
ts.server = server
ts.isRunning = true
log.Printf("🔄 TURN服务器启动成功监听端口: %d", ts.config.Port)
log.Printf(" 用户名: %s, 域: %s", ts.config.Username, ts.config.Realm)
return nil
}
// Stop 停止TURN服务器
func (ts *TurnService) Stop() error {
ts.mu.Lock()
defer ts.mu.Unlock()
if !ts.isRunning {
return fmt.Errorf("TURN服务器未运行")
}
if ts.server != nil {
if err := ts.server.Close(); err != nil {
return fmt.Errorf("关闭TURN服务器失败: %v", err)
}
}
ts.isRunning = false
log.Printf("🛑 TURN服务器已停止")
return nil
}
// IsRunning 检查TURN服务器是否正在运行
func (ts *TurnService) IsRunning() bool {
ts.mu.RLock()
defer ts.mu.RUnlock()
return ts.isRunning
}
// authHandler 认证处理器
func (ts *TurnService) authHandler(username string, realm string, srcAddr net.Addr) ([]byte, bool) {
// 记录连接统计
ts.stats.mu.Lock()
ts.stats.Connections++
ts.stats.mu.Unlock()
log.Printf("🔐 TURN认证请求: 用户=%s, 域=%s, 地址=%s", username, realm, srcAddr.String())
// 简单的用户名密码验证
if username == ts.config.Username && realm == ts.config.Realm {
// 记录分配统计
ts.stats.mu.Lock()
ts.stats.ActiveAllocations++
ts.stats.TotalAllocations++
ts.stats.mu.Unlock()
log.Printf("📊 TURN认证成功: 活跃分配=%d, 总分配=%d", ts.stats.ActiveAllocations, ts.stats.TotalAllocations)
// 返回密码的key
return turn.GenerateAuthKey(username, ts.config.Realm, ts.config.Password), true
}
log.Printf("❌ TURN认证失败: 用户=%s", username)
return nil, false
}
// GetStats 获取统计信息
func (ts *TurnService) GetStats() TurnStatsResponse {
ts.stats.mu.RLock()
defer ts.stats.mu.RUnlock()
return TurnStatsResponse{
IsRunning: ts.IsRunning(),
ActiveAllocations: ts.stats.ActiveAllocations,
TotalAllocations: ts.stats.TotalAllocations,
BytesTransferred: ts.stats.BytesTransferred,
PacketsTransferred: ts.stats.PacketsTransferred,
Connections: ts.stats.Connections,
Port: ts.config.Port,
Username: ts.config.Username,
Realm: ts.config.Realm,
}
}
// GetTurnServerInfo 获取TURN服务器信息用于客户端
func (ts *TurnService) GetTurnServerInfo() TurnServerInfo {
if !ts.IsRunning() {
return TurnServerInfo{}
}
return TurnServerInfo{
URLs: []string{fmt.Sprintf("turn:localhost:%d", ts.config.Port)},
Username: ts.config.Username,
Credential: ts.config.Password,
}
}
// UpdateStats 更新传输统计 (可以从外部调用)
func (ts *TurnService) UpdateStats(bytes, packets int64) {
ts.stats.mu.Lock()
defer ts.stats.mu.Unlock()
ts.stats.BytesTransferred += bytes
ts.stats.PacketsTransferred += packets
}
// DecrementActiveAllocations 减少活跃分配数(当连接关闭时调用)
func (ts *TurnService) DecrementActiveAllocations() {
ts.stats.mu.Lock()
defer ts.stats.mu.Unlock()
if ts.stats.ActiveAllocations > 0 {
ts.stats.ActiveAllocations--
log.Printf("📊 TURN分配释放: 活跃分配=%d", ts.stats.ActiveAllocations)
}
}
// TurnStatsResponse TURN统计响应结构
type TurnStatsResponse struct {
IsRunning bool `json:"isRunning"`
ActiveAllocations int64 `json:"activeAllocations"`
TotalAllocations int64 `json:"totalAllocations"`
BytesTransferred int64 `json:"bytesTransferred"`
PacketsTransferred int64 `json:"packetsTransferred"`
Connections int64 `json:"connections"`
Port int `json:"port"`
Username string `json:"username"`
Realm string `json:"realm"`
}
// TurnServerInfo TURN服务器信息结构 (用于WebRTC配置)
type TurnServerInfo struct {
URLs []string `json:"urls"`
Username string `json:"username"`
Credential string `json:"credential"`
}

View File

@@ -1,6 +1,7 @@
package services
import (
"encoding/json"
"fmt"
"log"
"math/rand"
@@ -23,7 +24,6 @@ type WebRTCRoom struct {
Receiver *WebRTCClient
CreatedAt time.Time
ExpiresAt time.Time // 添加过期时间
LastOffer *WebRTCMessage // 保存最后的offer消息
}
type WebRTCClient struct {
@@ -107,6 +107,20 @@ func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return
}
// 检查房间是否已满(两个连接都已存在)
ws.roomsMux.RLock()
isRoomFull := room.Sender != nil && room.Receiver != nil
ws.roomsMux.RUnlock()
if isRoomFull {
log.Printf("房间已满,拒绝连接: %s", code)
conn.WriteJSON(map[string]interface{}{
"type": "error",
"message": "当前房间人数已满,正在传输中无法加入",
})
return
}
// 生成客户端ID
clientID := ws.generateClientID()
client := &WebRTCClient{
@@ -133,18 +147,36 @@ func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request)
// 处理消息
for {
var msg WebRTCMessage
err := conn.ReadJSON(&msg)
// 首先读取原始消息类型和数据
messageType, data, err := conn.ReadMessage()
if err != nil {
log.Printf("读取WebRTC WebSocket消息失败: %v", err)
break
}
msg.From = clientID
log.Printf("收到WebRTC信令: 类型=%s, 来自=%s, 房间=%s", msg.Type, clientID, code)
if messageType == websocket.TextMessage {
// 文本消息尝试解析为JSON
var msg WebRTCMessage
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("解析WebRTC JSON消息失败: %v", err)
continue
}
// 转发信令消息给对方
ws.forwardMessage(code, clientID, &msg)
msg.From = clientID
log.Printf("收到WebRTC信令: 类型=%s, 来自=%s, 房间=%s", msg.Type, clientID, code)
// 转发信令消息给对方
ws.forwardMessage(code, clientID, &msg)
} else if messageType == websocket.BinaryMessage {
// 二进制消息,直接转发
log.Printf("收到WebRTC二进制数据: 大小=%d bytes, 来自=%s, 房间=%s", len(data), clientID, code)
// 转发二进制数据给对方
ws.forwardBinaryMessage(code, clientID, data)
} else {
log.Printf("收到未知消息类型: %d", messageType)
}
}
}
@@ -187,15 +219,6 @@ func (ws *WebRTCService) addClientToRoom(code string, client *WebRTCClient) {
}
room.Sender.Connection.WriteJSON(peerJoinedMsg)
}
// 如果接收方连接且有保存的offer立即发送给接收方
if room.LastOffer != nil {
log.Printf("向新连接的接收方发送保存的offer")
err := client.Connection.WriteJSON(room.LastOffer)
if err != nil {
log.Printf("发送保存的offer失败: %v", err)
}
}
}
}
@@ -233,12 +256,6 @@ func (ws *WebRTCService) forwardMessage(roomCode string, fromClientID string, ms
return
}
// 如果是offer消息保存起来
if msg.Type == "offer" {
room.LastOffer = msg
log.Printf("保存offer消息等待接收方连接")
}
var targetClient *WebRTCClient
if room.Sender != nil && room.Sender.ID == fromClientID {
// 消息来自sender转发给receiver
@@ -261,6 +278,37 @@ func (ws *WebRTCService) forwardMessage(roomCode string, fromClientID string, ms
}
}
// 转发二进制消息
func (ws *WebRTCService) forwardBinaryMessage(roomCode string, fromClientID string, data []byte) {
ws.roomsMux.Lock()
defer ws.roomsMux.Unlock()
room := ws.rooms[roomCode]
if room == nil {
return
}
var targetClient *WebRTCClient
if room.Sender != nil && room.Sender.ID == fromClientID {
// 消息来自sender转发给receiver
targetClient = room.Receiver
} else if room.Receiver != nil && room.Receiver.ID == fromClientID {
// 消息来自receiver转发给sender
targetClient = room.Sender
}
if targetClient != nil && targetClient.Connection != nil {
err := targetClient.Connection.WriteMessage(websocket.BinaryMessage, data)
if err != nil {
log.Printf("转发WebRTC二进制数据失败: %v", err)
} else {
log.Printf("转发WebRTC二进制数据: 大小=%d bytes, 从=%s到=%s", len(data), fromClientID, targetClient.ID)
}
} else {
log.Printf("目标客户端不在线,无法转发二进制数据")
}
}
// CreateRoom 创建或获取房间
func (ws *WebRTCService) CreateRoom(code string) {
ws.roomsMux.Lock()
@@ -374,6 +422,7 @@ func (ws *WebRTCService) notifyRoomDisconnection(roomCode string, disconnectedCl
}
}
}
func (ws *WebRTCService) GetRoomStatus(code string) map[string]interface{} {
ws.roomsMux.RLock()
defer ws.roomsMux.RUnlock()
@@ -387,11 +436,15 @@ func (ws *WebRTCService) GetRoomStatus(code string) map[string]interface{} {
}
}
// 检查房间是否已满(两个连接都已存在)
isRoomFull := room.Sender != nil && room.Receiver != nil
return map[string]interface{}{
"success": true,
"exists": true,
"sender_online": room.Sender != nil,
"receiver_online": room.Receiver != nil,
"is_room_full": isRoomFull,
"created_at": room.CreatedAt,
}
}

View File

@@ -5,7 +5,9 @@ import (
"io"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strings"
)
@@ -25,6 +27,15 @@ func hasFrontendFiles() bool {
// CreateFrontendHandler 创建前端文件处理器
func CreateFrontendHandler() http.Handler {
// 检查是否配置了外部前端目录
if frontendDir := os.Getenv("FRONTEND_DIR"); frontendDir != "" {
if info, err := os.Stat(frontendDir); err == nil && info.IsDir() {
// 使用外部前端目录
return &externalSpaHandler{baseDir: frontendDir}
}
}
// 使用内嵌的前端文件
if !hasFrontendFiles() {
return &placeholderHandler{}
}
@@ -59,6 +70,7 @@ func (h *placeholderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
pre { margin: 0; overflow-x: auto; }
.api-list { margin: 20px 0; }
.api-item { margin: 10px 0; padding: 10px; background: #e3f2fd; border-radius: 4px; }
.env-config { background: #e8f5e8; padding: 15px; border-radius: 4px; margin: 20px 0; }
</style>
</head>
<body>
@@ -69,11 +81,21 @@ func (h *placeholderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
⚠️ 前端界面未构建,当前显示的是后端 API 服务。
</div>
<h2>📋 可用的 API 接口</h2>
<h2><EFBFBD> 环境变量配置</h2>
<div class="env-config">
<strong>FRONTEND_DIR</strong> - 指定外部前端文件目录<br>
<strong>PORT</strong> - 自定义服务端口 (默认: 8080)<br><br>
<strong>示例:</strong><br>
<pre>export FRONTEND_DIR=/path/to/frontend
export PORT=3000
./file-transfer-server</pre>
</div>
<h2><3E>📋 可用的 API 接口</h2>
<div class="api-list">
<div class="api-item"><strong>POST</strong> /api/create-text-room - 创建文本传输房间</div>
<div class="api-item"><strong>GET</strong> /api/get-text-content/* - 获取文本内容</div>
<div class="api-item"><strong>WebSocket</strong> /ws/webrtc - WebRTC 信令连接</div>
<div class="api-item"><strong>POST</strong> /api/create-room - 创建WebRTC房间</div>
<div class="api-item"><strong>GET</strong> /api/room-info - 获取房间信息</div>
<div class="api-item"><strong>WebSocket</strong> /api/ws/webrtc - WebRTC 信令连接</div>
</div>
<h2>🛠️ 构建前端</h2>
@@ -82,14 +104,18 @@ func (h *placeholderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
cd chuan-next
# 安装依赖
yarn install
npm install
# 构建静态文件
yarn build:ssg
npm run build
# 重新构建 Go 项目以嵌入前端文件
# 方法1: 重新构建 Go 项目以嵌入前端文件
cd ..
go build -o file-transfer-server ./cmd</pre>
go build -o file-transfer-server ./cmd
# 方法2: 使用外部前端目录
export FRONTEND_DIR=./chuan-next/out
./file-transfer-server</pre>
</div>
<p><strong>提示:</strong> 构建完成后刷新页面即可看到完整的前端界面。</p>
@@ -99,6 +125,61 @@ go build -o file-transfer-server ./cmd</pre>
`))
}
// externalSpaHandler 外部文件目录处理器
type externalSpaHandler struct {
baseDir string
}
func (h *externalSpaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 清理路径
upath := strings.TrimPrefix(r.URL.Path, "/")
if upath == "" {
upath = "index.html"
}
// 构建完整文件路径
fullPath := filepath.Join(h.baseDir, upath)
// 安全检查:确保文件在基础目录内
absBasePath, err := filepath.Abs(h.baseDir)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
absFullPath, err := filepath.Abs(fullPath)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if !strings.HasPrefix(absFullPath, absBasePath) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// 检查文件是否存在
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
// 文件不存在,对于 SPA 应用返回 index.html
h.serveIndexHTML(w, r)
return
}
// 服务文件
http.ServeFile(w, r, fullPath)
}
// serveIndexHTML 服务外部目录的 index.html 文件
func (h *externalSpaHandler) serveIndexHTML(w http.ResponseWriter, r *http.Request) {
indexPath := filepath.Join(h.baseDir, "index.html")
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, indexPath)
}
// spaHandler SPA 应用处理器
type spaHandler struct {
fs fs.FS

View File

@@ -1,52 +0,0 @@
package web
import (
"embed"
"io/fs"
"net/http"
)
// 嵌入静态文件
//
//go:embed all:static
var StaticFiles embed.FS
// StaticFileServer 创建静态文件服务器
func StaticFileServer() http.Handler {
// 获取嵌入的文件系统
staticFS, err := fs.Sub(StaticFiles, "static")
if err != nil {
panic(err)
}
return http.FileServer(http.FS(staticFS))
}
// FrontendFileServer 创建前端文件服务器
func FrontendFileServer() http.Handler {
return &frontendHandler{}
}
// frontendHandler 处理前端文件请求,支持 SPA 路由
type frontendHandler struct{}
func (h *frontendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 返回一个简单的占位页面
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head>
<title>文件传输</title>
<meta charset="utf-8">
</head>
<body>
<h1>文件传输服务</h1>
<p>前端文件未嵌入,请先构建前端项目。</p>
<p>运行以下命令构建前端:</p>
<pre>cd chuan-next && yarn build:ssg</pre>
</body>
</html>
`))
}