mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-11 23:50:35 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7485059cf | ||
|
|
04d4af5ef1 | ||
|
|
08f9d50e66 | ||
|
|
2fc478e889 | ||
|
|
15d23de5a7 | ||
|
|
550be8bcc6 | ||
|
|
50d30f23bf | ||
|
|
4b31e76488 | ||
|
|
84d7caea8c | ||
|
|
343e7f1192 | ||
|
|
07409abb3b |
12
.chuan.env
Normal file
12
.chuan.env
Normal file
@@ -0,0 +1,12 @@
|
||||
# 文件传输服务器配置
|
||||
|
||||
# 主服务器配置
|
||||
PORT=8080
|
||||
# FRONTEND_DIR=./dist
|
||||
|
||||
# TURN服务器配置
|
||||
TURN_ENABLED=false
|
||||
TURN_PORT=3478
|
||||
TURN_USERNAME=chuan
|
||||
TURN_PASSWORD=chuan123
|
||||
TURN_REALM=localhost
|
||||
23
.chuan.env.example
Normal file
23
.chuan.env.example
Normal 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. 值可以用单引号或双引号包围
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -109,4 +109,7 @@ backup/
|
||||
./chuan/.next
|
||||
./internal/web/frontend/*
|
||||
./file-transfer-server
|
||||
file-transfer-server
|
||||
file-transfer-server
|
||||
./chuan-vue
|
||||
./chuan-vue/*
|
||||
chuan-vue
|
||||
@@ -30,6 +30,8 @@
|
||||
- 🖥️ **多平台支持** - 支持linux/macos/win 单文件部署
|
||||
|
||||
## 🔄 最近更新日志
|
||||
### 2025-11-24
|
||||
- ✅ **共享桌面** - 共享桌面支持开启语音,提升实用性
|
||||
|
||||
### 2025-09-5
|
||||
- ✅ **WEBRTC链接恢复** - 关闭页面后在打开,进行数据链接恢复
|
||||
@@ -179,6 +181,7 @@ make dev
|
||||
cd chuan-next && yarn && yarn dev
|
||||
```
|
||||
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
@@ -192,3 +195,5 @@ MIT License
|
||||
[]
|
||||
|
||||
</div>
|
||||
|
||||
[](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
|
||||
|
||||
37
_deploy.sh
Executable file
37
_deploy.sh
Executable 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 "✅ 部署完成"
|
||||
0
chuan-next/build-static.sh
Normal file → Executable file
0
chuan-next/build-static.sh
Normal file → Executable file
@@ -1,32 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Upload, MessageSquare, Monitor, Users, Settings } 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,
|
||||
const {
|
||||
activeTab,
|
||||
handleTabChange,
|
||||
confirmDialogState,
|
||||
closeConfirmDialog
|
||||
} = useTabNavigation();
|
||||
|
||||
|
||||
// WebRTC 支持检测
|
||||
const {
|
||||
webrtcSupport,
|
||||
@@ -45,134 +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-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>
|
||||
|
||||
{/* 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>
|
||||
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
|
||||
<DesktopShare />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCSettings />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
<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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React 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,18 @@ interface ConnectionStatusProps {
|
||||
}
|
||||
|
||||
// 连接状态枚举
|
||||
const getConnectionStatus = (connection: { isWebSocketConnected?: boolean; isPeerConnected?: boolean; isConnecting?: boolean; error?: string | null }, currentRoom: { code: string; role: 'sender' | 'receiver' } | null) => {
|
||||
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: string;
|
||||
isJoinedRoom: boolean;
|
||||
}
|
||||
) => {
|
||||
const { isWebSocketConnected, isPeerConnected, isConnecting, error, currentConnectType, isJoinedRoom } = connection;
|
||||
|
||||
if (!currentRoom) {
|
||||
return {
|
||||
@@ -44,17 +36,74 @@ const getConnectionStatus = (connection: { isWebSocketConnected?: boolean; isPee
|
||||
};
|
||||
}
|
||||
|
||||
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 +111,8 @@ const getConnectionStatus = (connection: { isWebSocketConnected?: boolean; isPee
|
||||
};
|
||||
}
|
||||
|
||||
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 +128,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 +151,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,74 +163,80 @@ const StatusIcon = ({ type, className = 'w-3 h-3' }: { type: string; className?:
|
||||
};
|
||||
|
||||
// 获取连接状态文字描述
|
||||
const getConnectionStatusText = (connection: { isWebSocketConnected?: boolean; isPeerConnected?: boolean; isConnecting?: boolean; error?: string | null }) => {
|
||||
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();
|
||||
|
||||
|
||||
// 创建connection对象以兼容现有代码
|
||||
const connection = {
|
||||
isWebSocketConnected: webrtcState.isWebSocketConnected,
|
||||
isPeerConnected: webrtcState.isPeerConnected,
|
||||
isConnecting: webrtcState.isConnecting,
|
||||
error: webrtcState.error,
|
||||
currentConnectType: webrtcState.currentConnectType,
|
||||
isJoinedRoom: webrtcState.isJoinedRoom,
|
||||
};
|
||||
|
||||
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 ?? null);
|
||||
|
||||
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>
|
||||
@@ -207,9 +260,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'
|
||||
@@ -217,14 +270,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'
|
||||
|
||||
@@ -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);
|
||||
@@ -44,15 +44,16 @@ export default function DesktopViewer({
|
||||
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] 📹 视频开始加载');
|
||||
@@ -112,14 +113,14 @@ export default function DesktopViewer({
|
||||
}
|
||||
}, 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);
|
||||
@@ -168,25 +169,25 @@ 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] ⏸️ 退出全屏后视频已暂停,显示播放按钮');
|
||||
@@ -204,7 +205,7 @@ export default function DesktopViewer({
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
@@ -214,12 +215,12 @@ export default function DesktopViewer({
|
||||
const handleMouseMove = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
setShowControls(true);
|
||||
|
||||
|
||||
// 清除之前的定时器
|
||||
if (hideControlsTimeoutRef.current) {
|
||||
clearTimeout(hideControlsTimeoutRef.current);
|
||||
}
|
||||
|
||||
|
||||
// 3秒后自动隐藏控制栏
|
||||
hideControlsTimeoutRef.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
@@ -254,7 +255,7 @@ export default function DesktopViewer({
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
@@ -393,7 +394,7 @@ 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'
|
||||
}}
|
||||
@@ -425,9 +426,8 @@ 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 flex-col sm:flex-row items-center justify-between gap-4">
|
||||
{/* 左侧信息 */}
|
||||
@@ -535,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>
|
||||
|
||||
64
chuan-next/src/components/Footer.tsx
Normal file
64
chuan-next/src/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,6 @@ 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>
|
||||
|
||||
@@ -25,7 +23,7 @@ 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>
|
||||
|
||||
<Link
|
||||
@@ -37,19 +35,8 @@ export default function Hero() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<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"
|
||||
>
|
||||
https://github.com/MatrixSeven/file-transfer-go
|
||||
</a>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
92
chuan-next/src/components/VoiceIndicator.tsx
Normal file
92
chuan-next/src/components/VoiceIndicator.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { Mic, MicOff } from 'lucide-react';
|
||||
|
||||
interface VoiceIndicatorProps {
|
||||
volume: number; // 0-100
|
||||
isSpeaking: boolean;
|
||||
isMuted?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VoiceIndicator({
|
||||
volume,
|
||||
isSpeaking,
|
||||
isMuted = false,
|
||||
className = '',
|
||||
}: VoiceIndicatorProps) {
|
||||
// 根据音量计算波纹大小
|
||||
const rippleScale = 1 + (volume / 100) * 0.8; // 1.0 到 1.8
|
||||
|
||||
// 音量条数量(5条)
|
||||
const barCount = 5;
|
||||
const activeBars = Math.ceil((volume / 100) * barCount);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center space-x-2 ${className}`}>
|
||||
{/* 麦克风图标和波纹效果 */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
{/* 波纹动画 - 只在说话时显示 */}
|
||||
{isSpeaking && !isMuted && (
|
||||
<>
|
||||
<div
|
||||
className="absolute w-10 h-10 rounded-full bg-green-500 opacity-20 animate-ping"
|
||||
style={{
|
||||
animationDuration: '1s',
|
||||
transform: `scale(${rippleScale})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute w-10 h-10 rounded-full bg-green-400 opacity-30"
|
||||
style={{
|
||||
transform: `scale(${rippleScale})`,
|
||||
transition: 'transform 0.1s ease-out',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 麦克风图标 */}
|
||||
<div
|
||||
className={`relative z-10 w-8 h-8 rounded-full flex items-center justify-center transition-colors ${
|
||||
isMuted
|
||||
? 'bg-red-100 text-red-600'
|
||||
: isSpeaking
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{isMuted ? (
|
||||
<MicOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Mic className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音量条 - 10个等级 */}
|
||||
<div className="flex items-center space-x-0.5">
|
||||
{Array.from({ length: barCount }).map((_, index) => {
|
||||
const isActive = index < activeBars && !isMuted;
|
||||
const height = 8 + index * 1.5; // 递增高度: 8, 9.5, 11, 12.5... 到 21.5
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-1 rounded-full transition-all duration-150 ${
|
||||
isActive
|
||||
? isSpeaking
|
||||
? 'bg-green-500'
|
||||
: 'bg-slate-400'
|
||||
: 'bg-slate-200'
|
||||
}`}
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
opacity: isActive ? 1 : 0.3,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,12 +76,13 @@ 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 } = useRoomConnection({
|
||||
@@ -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,
|
||||
@@ -409,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(() => {
|
||||
@@ -635,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}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Settings,
|
||||
Plus,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Info,
|
||||
Server,
|
||||
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,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Database,
|
||||
X,
|
||||
Wifi
|
||||
Trash2,
|
||||
Wifi,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useIceServersConfig, IceServerConfig } from '@/hooks/settings/useIceServersConfig';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useWebRTCStore } from '@/hooks/ui/webRTCStore';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface AddServerModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -251,8 +251,6 @@ interface ServerItemProps {
|
||||
}
|
||||
|
||||
function ServerItem({ server, onRemove, canRemove }: ServerItemProps) {
|
||||
const isDefault = server.id.startsWith('google-') || server.id.startsWith('twilio-');
|
||||
|
||||
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">
|
||||
@@ -261,8 +259,8 @@ function ServerItem({ server, onRemove, canRemove }: ServerItemProps) {
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
{server.type?.toUpperCase() || 'STUN'}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
{server.isDefault && (
|
||||
<span className="text-xs bg-green-100 text-green-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
@@ -341,7 +339,8 @@ export default function WebRTCSettings() {
|
||||
removeIceServer(serverToDelete);
|
||||
showToast('ICE服务器删除成功', 'success');
|
||||
} catch (error) {
|
||||
showToast('至少需要保留一个ICE服务器', 'error');
|
||||
const errorMessage = error instanceof Error ? error.message : '删除失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setServerToDelete(null);
|
||||
setShowDeleteDialog(false);
|
||||
@@ -518,6 +517,13 @@ export default function WebRTCSettings() {
|
||||
格式:<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>
|
||||
@@ -537,7 +543,19 @@ export default function WebRTCSettings() {
|
||||
onClose={cancelDeleteServer}
|
||||
onConfirm={confirmDeleteServer}
|
||||
title="删除ICE服务器"
|
||||
message={`确定要删除这个ICE服务器吗?删除后将无法恢复。${iceServers.length <= 1 ? '\n\n注意:这是最后一个服务器,删除后将无法建立WebRTC连接。' : ''}`}
|
||||
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"
|
||||
|
||||
@@ -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 = () => {
|
||||
// 状态管理
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Monitor, Square } from 'lucide-react';
|
||||
import { Monitor, Square, Mic, MicOff } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useDesktopShareBusiness } from '@/hooks/desktop-share';
|
||||
import { useVoiceChatBusiness } from '@/hooks/desktop-share/useVoiceChatBusiness';
|
||||
import { VoiceIndicator } from '@/components/VoiceIndicator';
|
||||
import DesktopViewer from '@/components/DesktopViewer';
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
|
||||
@@ -24,6 +26,26 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
|
||||
|
||||
// 使用桌面共享业务逻辑
|
||||
const desktopShare = useDesktopShareBusiness();
|
||||
|
||||
// 使用语音通话业务逻辑
|
||||
const voiceChat = useVoiceChatBusiness(desktopShare.webRTCConnection);
|
||||
|
||||
// 远程音频元素引用
|
||||
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// 调试:监控语音状态变化(只监听状态,不监听实时音量)
|
||||
useEffect(() => {
|
||||
console.log('[DesktopShareReceiver] 🎤 语音状态变化:', {
|
||||
isVoiceEnabled: voiceChat.isVoiceEnabled,
|
||||
isRemoteVoiceActive: voiceChat.isRemoteVoiceActive,
|
||||
debug: voiceChat._debug
|
||||
});
|
||||
}, [
|
||||
voiceChat.isVoiceEnabled,
|
||||
voiceChat.isRemoteVoiceActive
|
||||
// 不监听 localVolume, remoteVolume, localIsSpeaking, remoteIsSpeaking
|
||||
// 这些值每帧都在变化(约60fps),会导致过度渲染
|
||||
]);
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
useEffect(() => {
|
||||
@@ -117,7 +139,7 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
|
||||
}
|
||||
}, [desktopShare, inputCode, isJoiningRoom, showToast]);
|
||||
|
||||
// 停止观看
|
||||
// 停止观看桌面
|
||||
const handleStopViewing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -132,6 +154,34 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 开启语音
|
||||
const handleEnableVoice = useCallback(async () => {
|
||||
try {
|
||||
console.log('[DesktopShareReceiver] 用户点击开启语音');
|
||||
await voiceChat.enableVoice();
|
||||
showToast('语音已开启', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareReceiver] 开启语音失败:', error);
|
||||
let errorMessage = '开启语音失败';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('麦克风权限') || error.message.includes('Permission')) {
|
||||
errorMessage = '无法访问麦克风,请检查浏览器权限设置';
|
||||
} else if (error.message.includes('P2P连接')) {
|
||||
errorMessage = '请先等待连接建立';
|
||||
} else if (error.message.includes('NotFoundError') || error.message.includes('设备')) {
|
||||
errorMessage = '未检测到麦克风设备';
|
||||
} else if (error.message.includes('NotAllowedError')) {
|
||||
errorMessage = '麦克风权限被拒绝,请在浏览器设置中允许使用麦克风';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMessage, 'error');
|
||||
}
|
||||
}, [voiceChat, showToast]);
|
||||
|
||||
// 如果有初始代码且还未加入观看,自动尝试加入
|
||||
React.useEffect(() => {
|
||||
console.log('[WebRTCDesktopReceiver] useEffect 触发, 参数:', {
|
||||
@@ -320,50 +370,143 @@ export default function WebRTCDesktopReceiver({ className, initialCode, onConnec
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 观看中的控制面板 */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-white rounded-lg p-3 shadow-lg border flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="font-semibold">观看中</span>
|
||||
{/* 观看中的控制面板 - 移动端优化 */}
|
||||
<div className="mb-4">
|
||||
<div className="bg-white rounded-lg p-3 shadow-lg border">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
|
||||
{/* 状态指示 */}
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="font-semibold">观看中</span>
|
||||
</div>
|
||||
|
||||
{/* 对方说话提示 - 移动端全宽 */}
|
||||
{voiceChat.isRemoteVoiceActive && voiceChat.remoteIsSpeaking && (
|
||||
<div className="flex items-center space-x-2 bg-green-50 text-green-700 px-3 py-1.5 rounded-lg border border-green-200 animate-pulse">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
|
||||
<Mic className="w-3.5 h-3.5" />
|
||||
<span className="text-sm font-medium">对方正在讲话</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 按钮组 - 移动端全宽横向 */}
|
||||
<div className="flex gap-2 sm:ml-auto w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={voiceChat.isVoiceEnabled ? () => voiceChat.disableVoice() : handleEnableVoice}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 sm:flex-initial ${
|
||||
voiceChat.isVoiceEnabled
|
||||
? "text-green-600 border-green-300"
|
||||
: "text-slate-600 border-slate-300"
|
||||
}`}
|
||||
disabled={!desktopShare.isPeerConnected && !voiceChat.isVoiceEnabled}
|
||||
>
|
||||
{voiceChat.isVoiceEnabled ? (
|
||||
<>
|
||||
<Mic className="w-4 h-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">关闭发言</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MicOff className="w-4 h-4 sm:mr-1" />
|
||||
<span className="hidden sm:inline">开启发言</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleStopViewing}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-initial"
|
||||
>
|
||||
<Square className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">{isLoading ? '退出中...' : '退出观看'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleStopViewing}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '退出中...' : '退出观看'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面显示区域 */}
|
||||
{desktopShare.remoteStream ? (
|
||||
<DesktopViewer
|
||||
stream={desktopShare.remoteStream}
|
||||
isConnected={desktopShare.isViewing}
|
||||
connectionCode={inputCode}
|
||||
onDisconnect={handleStopViewing}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-8 border border-slate-200">
|
||||
<div className="text-center">
|
||||
<Monitor className="w-16 h-16 mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-slate-600 mb-2">等待接收桌面画面...</p>
|
||||
<p className="text-sm text-slate-500">发送方开始共享后,桌面画面将在这里显示</p>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2 mt-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
|
||||
<span className="text-sm text-purple-600">等待桌面流...</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
{desktopShare.remoteStream ? (
|
||||
<DesktopViewer
|
||||
stream={desktopShare.remoteStream}
|
||||
isConnected={desktopShare.isViewing}
|
||||
connectionCode={inputCode}
|
||||
onDisconnect={handleStopViewing}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-8 border border-slate-200">
|
||||
<div className="text-center">
|
||||
<Monitor className="w-16 h-16 mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-slate-600 mb-2">等待接收桌面画面...</p>
|
||||
<p className="text-sm text-slate-500">发送方开始共享后,桌面画面将在这里显示</p>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2 mt-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
|
||||
<span className="text-sm text-purple-600">等待桌面流...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* 语音状态指示器 - 始终显示,点击切换 */}
|
||||
{desktopShare.remoteStream && (
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className="bg-gradient-to-br from-slate-50 to-white rounded-xl p-3 shadow-lg border border-slate-200 cursor-pointer hover:shadow-xl transition-shadow"
|
||||
onClick={voiceChat.isVoiceEnabled ? () => voiceChat.disableVoice() : handleEnableVoice}
|
||||
title={voiceChat.isVoiceEnabled ? "点击关闭发言" : "点击开启发言"}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
voiceChat.isVoiceEnabled ? 'bg-blue-100' : 'bg-slate-100'
|
||||
}`}>
|
||||
{voiceChat.isVoiceEnabled ? (
|
||||
<Mic className="w-4 h-4 text-blue-600" />
|
||||
) : (
|
||||
<MicOff className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-sm font-medium ${
|
||||
voiceChat.isVoiceEnabled ? 'text-slate-700' : 'text-slate-500'
|
||||
}`}>我的发言</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{voiceChat.isVoiceEnabled ? '点击关闭' : '点击开启'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{voiceChat.isVoiceEnabled && (
|
||||
<VoiceIndicator
|
||||
volume={voiceChat.localVolume}
|
||||
isSpeaking={voiceChat.localIsSpeaking}
|
||||
isMuted={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 隐藏的音频元素用于播放远程音频 */}
|
||||
<audio
|
||||
ref={(el) => {
|
||||
remoteAudioRef.current = el;
|
||||
voiceChat.setRemoteAudioRef(el);
|
||||
}}
|
||||
autoPlay
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"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 { useVoiceChatBusiness } from '@/hooks/desktop-share/useVoiceChatBusiness';
|
||||
import { VoiceIndicator } from '@/components/VoiceIndicator';
|
||||
import { Monitor, Repeat, Share, Square, Mic, MicOff } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface WebRTCDesktopSenderProps {
|
||||
className?: string;
|
||||
@@ -19,6 +21,60 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
|
||||
|
||||
// 使用桌面共享业务逻辑
|
||||
const desktopShare = useDesktopShareBusiness();
|
||||
|
||||
// 使用语音通话业务逻辑 - 传入同一个connection实例
|
||||
const voiceChat = useVoiceChatBusiness(desktopShare.webRTCConnection);
|
||||
|
||||
// 调试:监控语音状态变化(只监听状态,不监听实时音量)
|
||||
useEffect(() => {
|
||||
console.log('[DesktopShareSender] 🎤 语音状态变化:', {
|
||||
isVoiceEnabled: voiceChat.isVoiceEnabled,
|
||||
isRemoteVoiceActive: voiceChat.isRemoteVoiceActive,
|
||||
debug: voiceChat._debug
|
||||
});
|
||||
}, [
|
||||
voiceChat.isVoiceEnabled,
|
||||
voiceChat.isRemoteVoiceActive
|
||||
// 不监听 localVolume, remoteVolume, localIsSpeaking, remoteIsSpeaking
|
||||
// 这些值每帧都在变化(约60fps),会导致过度渲染
|
||||
]);
|
||||
|
||||
// 调试:监控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);
|
||||
|
||||
// 设置远程音频元素的回调
|
||||
const setRemoteAudioRef = useCallback((audioElement: HTMLAudioElement | null) => {
|
||||
voiceChat.setRemoteAudioRef(audioElement);
|
||||
}, [voiceChat]);
|
||||
|
||||
// 处理本地流变化,确保视频正确显示
|
||||
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(() => {
|
||||
@@ -27,24 +83,37 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
|
||||
}
|
||||
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
|
||||
|
||||
// 监听连接状态变化,当P2P连接断开时重置共享状态
|
||||
// 监听连接状态变化,当P2P连接断开时保持桌面共享状态
|
||||
const prevPeerConnectedRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 如果正在共享但P2P连接断开,自动重置共享状态
|
||||
if (desktopShare.isSharing && !desktopShare.isPeerConnected && desktopShare.connectionCode) {
|
||||
console.log('[DesktopShareSender] 检测到P2P连接断开,自动重置共享状态');
|
||||
|
||||
const resetState = async () => {
|
||||
// 只有从连接状态变为断开状态时才处理
|
||||
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.resetSharing();
|
||||
console.log('[DesktopShareSender] 已自动重置共享状态');
|
||||
await desktopShare.handlePeerDisconnect();
|
||||
console.log('[DesktopShareSender] 已处理P2P断开,保持桌面共享状态');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 自动重置共享状态失败:', error);
|
||||
console.error('[DesktopShareSender] 处理P2P断开失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
resetState();
|
||||
|
||||
handleDisconnect();
|
||||
}
|
||||
}, [desktopShare.isSharing, desktopShare.isPeerConnected, desktopShare.connectionCode, desktopShare.resetSharing]);
|
||||
}, [desktopShare.isSharing, desktopShare.isPeerConnected, desktopShare.connectionCode]); // 移除handlePeerDisconnect依赖
|
||||
|
||||
// 复制房间代码
|
||||
const copyCode = useCallback(async (code: string) => {
|
||||
@@ -57,15 +126,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);
|
||||
@@ -81,19 +169,19 @@ 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();
|
||||
// await desktopShare.resetSharing();
|
||||
console.log('[DesktopShareSender] 已重置共享状态,用户可以重新选择桌面');
|
||||
} catch (resetError) {
|
||||
console.error('[DesktopShareSender] 重置共享状态失败:', resetError);
|
||||
@@ -108,16 +196,16 @@ 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();
|
||||
@@ -135,10 +223,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);
|
||||
@@ -149,6 +237,34 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 开启语音
|
||||
const handleEnableVoice = useCallback(async () => {
|
||||
try {
|
||||
console.log('[DesktopShareSender] 用户点击开启语音');
|
||||
await voiceChat.enableVoice();
|
||||
showToast('语音已开启', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 开启语音失败:', error);
|
||||
let errorMessage = '开启语音失败';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('麦克风权限') || error.message.includes('Permission')) {
|
||||
errorMessage = '无法访问麦克风,请检查浏览器权限设置';
|
||||
} else if (error.message.includes('P2P连接')) {
|
||||
errorMessage = '请先等待对方加入';
|
||||
} else if (error.message.includes('NotFoundError') || error.message.includes('设备')) {
|
||||
errorMessage = '未检测到麦克风设备';
|
||||
} else if (error.message.includes('NotAllowedError')) {
|
||||
errorMessage = '麦克风权限被拒绝,请在浏览器设置中允许使用麦克风';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMessage, 'error');
|
||||
}
|
||||
}, [voiceChat, showToast]);
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 sm:space-y-6 ${className || ''}`}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
@@ -166,8 +282,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>
|
||||
@@ -178,9 +294,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"
|
||||
>
|
||||
@@ -192,7 +308,7 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
|
||||
) : (
|
||||
<>
|
||||
<Share className="w-5 h-5 mr-2" />
|
||||
创建桌面共享房间
|
||||
开始桌面共享
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -212,8 +328,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>
|
||||
@@ -226,67 +342,122 @@ 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!desktopShare.isSharing ? (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleStartSharing}
|
||||
disabled={isLoading || !desktopShare.isPeerConnected}
|
||||
className={`w-full px-8 py-3 text-lg font-medium rounded-xl shadow-lg ${
|
||||
desktopShare.isPeerConnected
|
||||
? 'bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
{isLoading ? '启动中...' : '选择并开始共享桌面'}
|
||||
</Button>
|
||||
|
||||
{!desktopShare.isPeerConnected && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
等待接收方加入房间建立P2P连接...
|
||||
</p>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
|
||||
<span className="text-sm text-purple-600">正在等待连接</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center space-x-2 text-green-600 mb-4">
|
||||
<Play className="w-5 h-5" />
|
||||
<span className="font-semibold">桌面共享进行中</span>
|
||||
</div>
|
||||
<div className="flex justify-center space-x-3">
|
||||
<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-2" />
|
||||
{isLoading ? '切换中...' : '切换桌面'}
|
||||
<Repeat className="w-4 h-4 mr-1" />
|
||||
切换桌面
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStopSharing}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
<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>
|
||||
|
||||
{/* 语音控制按钮 */}
|
||||
<Button
|
||||
onClick={voiceChat.isVoiceEnabled ? voiceChat.disableVoice : handleEnableVoice}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={voiceChat.isVoiceEnabled
|
||||
? "text-green-700 border-green-300 hover:bg-green-50"
|
||||
: "text-slate-700 border-slate-300 hover:bg-slate-50"
|
||||
}
|
||||
>
|
||||
{voiceChat.isVoiceEnabled ? (
|
||||
<>
|
||||
<Mic className="w-4 h-4 mr-1" />
|
||||
关闭发言
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MicOff className="w-4 h-4 mr-1" />
|
||||
开启发言
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 本地预览区域(显示正在共享的内容) */}
|
||||
{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 className="absolute bottom-2 right-2 z-10">
|
||||
<div
|
||||
className="bg-gradient-to-br from-slate-50/95 to-white/95 backdrop-blur rounded-xl p-3 shadow-xl border border-slate-200/50 cursor-pointer hover:shadow-2xl transition-shadow"
|
||||
onClick={voiceChat.isVoiceEnabled ? voiceChat.disableVoice : handleEnableVoice}
|
||||
title={voiceChat.isVoiceEnabled ? "点击关闭发言" : "点击开启发言"}
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '停止中...' : '停止共享'}
|
||||
</Button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
voiceChat.isVoiceEnabled ? 'bg-blue-100' : 'bg-slate-100'
|
||||
}`}>
|
||||
{voiceChat.isVoiceEnabled ? (
|
||||
<Mic className="w-4 h-4 text-blue-600" />
|
||||
) : (
|
||||
<MicOff className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-xs font-medium ${
|
||||
voiceChat.isVoiceEnabled ? 'text-slate-700' : 'text-slate-500'
|
||||
}`}>我的发言</span>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{voiceChat.isVoiceEnabled ? '点击关闭' : '点击开启'}
|
||||
</span>
|
||||
</div>
|
||||
{voiceChat.isVoiceEnabled && (
|
||||
<VoiceIndicator
|
||||
volume={voiceChat.localVolume}
|
||||
isSpeaking={voiceChat.localIsSpeaking}
|
||||
isMuted={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -316,6 +487,14 @@ export default function WebRTCDesktopSender({ className, onConnectionChange }: W
|
||||
showToast('观看链接已复制', 'success');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 隐藏的远程音频播放元素 - 用于播放观看方的语音 */}
|
||||
<audio
|
||||
ref={setRemoteAudioRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
@@ -140,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('重置取件码输入');
|
||||
@@ -149,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>
|
||||
{/* 功能标题和状态 */}
|
||||
@@ -162,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>
|
||||
@@ -184,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>
|
||||
@@ -242,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">
|
||||
@@ -266,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'
|
||||
@@ -289,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">
|
||||
@@ -343,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) => (
|
||||
@@ -367,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -336,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>
|
||||
|
||||
@@ -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
|
||||
? "在这里编辑文字内容... 💡 支持实时同步编辑,对方可以看到你的修改 💡 可以直接粘贴图片 (Ctrl+V)"
|
||||
: "等待对方加入P2P网络... 📡 建立连接后即可开始输入文字"
|
||||
}
|
||||
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" />
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// 连接相关的hooks
|
||||
export { useConnectionState } from './useConnectionState';
|
||||
export { useConnectManager } from './useConnectManager';
|
||||
export { useRoomConnection } from './useRoomConnection';
|
||||
export { useSharedWebRTCManager } from './useSharedWebRTCManager';
|
||||
export { useWebRTCSupport } from './useWebRTCSupport';
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
61
chuan-next/src/hooks/connection/state/webConnectStore.ts
Normal file
61
chuan-next/src/hooks/connection/state/webConnectStore.ts
Normal 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), // 完全重置到初始状态
|
||||
}));
|
||||
124
chuan-next/src/hooks/connection/types.ts
Normal file
124
chuan-next/src/hooks/connection/types.ts
Normal 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;
|
||||
|
||||
}
|
||||
355
chuan-next/src/hooks/connection/useConnectManager.ts
Normal file
355
chuan-next/src/hooks/connection/useConnectManager.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
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) {
|
||||
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) => {
|
||||
return currentConnectionRef.current.onTrack(callback);
|
||||
}, []); // 空依赖,使用 ref 确保总是获取最新的连接
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useWebRTCStateManager } from './useWebRTCStateManager';
|
||||
import { useWebRTCDataChannelManager, WebRTCMessage } from './useWebRTCDataChannelManager';
|
||||
import { useWebRTCTrackManager } from './useWebRTCTrackManager';
|
||||
import { useWebRTCConnectionCore } from './useWebRTCConnectionCore';
|
||||
|
||||
// 消息和数据处理器类型
|
||||
export type MessageHandler = (message: WebRTCMessage) => void;
|
||||
export type DataHandler = (data: ArrayBuffer) => void;
|
||||
|
||||
// WebRTC 连接接口
|
||||
export interface WebRTCConnection {
|
||||
// 状态
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean;
|
||||
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 {
|
||||
// 创建各个管理器实例
|
||||
const stateManager = useWebRTCStateManager();
|
||||
const dataChannelManager = useWebRTCDataChannelManager(stateManager);
|
||||
const trackManager = useWebRTCTrackManager(stateManager);
|
||||
const connectionCore = useWebRTCConnectionCore(
|
||||
stateManager,
|
||||
dataChannelManager,
|
||||
trackManager
|
||||
);
|
||||
|
||||
// 获取当前状态
|
||||
const state = stateManager.getState();
|
||||
|
||||
// 创建 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 trackManager.createOfferNow(pc, ws);
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 创建 offer 失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, [connectionCore, trackManager]);
|
||||
|
||||
// 返回统一的接口,保持与当前 API 一致
|
||||
return {
|
||||
// 状态
|
||||
isConnected: state.isConnected,
|
||||
isConnecting: state.isConnecting,
|
||||
isWebSocketConnected: state.isWebSocketConnected,
|
||||
isPeerConnected: state.isPeerConnected,
|
||||
error: state.error,
|
||||
canRetry: state.canRetry,
|
||||
|
||||
// 操作方法
|
||||
connect: connectionCore.connect,
|
||||
disconnect: () => connectionCore.disconnect(true),
|
||||
retry: connectionCore.retry,
|
||||
sendMessage: dataChannelManager.sendMessage,
|
||||
sendData: dataChannelManager.sendData,
|
||||
|
||||
// 处理器注册
|
||||
registerMessageHandler: dataChannelManager.registerMessageHandler,
|
||||
registerDataHandler: dataChannelManager.registerDataHandler,
|
||||
|
||||
// 工具方法
|
||||
getChannelState: dataChannelManager.getChannelState,
|
||||
isConnectedToRoom: stateManager.isConnectedToRoom,
|
||||
|
||||
// 媒体轨道方法
|
||||
addTrack: trackManager.addTrack,
|
||||
removeTrack: trackManager.removeTrack,
|
||||
onTrack: trackManager.onTrack,
|
||||
getPeerConnection: connectionCore.getPeerConnection,
|
||||
createOfferNow,
|
||||
|
||||
// 当前房间信息
|
||||
currentRoom: connectionCore.getCurrentRoom(),
|
||||
};
|
||||
}
|
||||
@@ -1,570 +0,0 @@
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { getWsUrl } from '@/lib/config';
|
||||
import { getIceServersConfig } from '../settings/useIceServersConfig';
|
||||
import { WebRTCStateManager } from './useWebRTCStateManager';
|
||||
import { WebRTCDataChannelManager, WebRTCMessage } from './useWebRTCDataChannelManager';
|
||||
import { WebRTCTrackManager } from './useWebRTCTrackManager';
|
||||
|
||||
/**
|
||||
* WebRTC 核心连接管理器
|
||||
* 负责基础的 WebRTC 连接管理
|
||||
*/
|
||||
export interface WebRTCConnectionCore {
|
||||
// 连接到房间
|
||||
connect: (roomCode: string, role: 'sender' | 'receiver') => Promise<void>;
|
||||
|
||||
// 断开连接
|
||||
disconnect: (shouldNotifyDisconnect?: boolean) => void;
|
||||
|
||||
// 重试连接
|
||||
retry: () => Promise<void>;
|
||||
|
||||
// 获取 PeerConnection 实例
|
||||
getPeerConnection: () => RTCPeerConnection | null;
|
||||
|
||||
// 获取 WebSocket 实例
|
||||
getWebSocket: () => WebSocket | null;
|
||||
|
||||
// 获取当前房间信息
|
||||
getCurrentRoom: () => { code: string; role: 'sender' | 'receiver' } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC 核心连接管理 Hook
|
||||
* 负责基础的 WebRTC 连接管理,包括 WebSocket 连接、PeerConnection 创建和管理
|
||||
*/
|
||||
export function useWebRTCConnectionCore(
|
||||
stateManager: WebRTCStateManager,
|
||||
dataChannelManager: WebRTCDataChannelManager,
|
||||
trackManager: WebRTCTrackManager
|
||||
): WebRTCConnectionCore {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | 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 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
currentRoom.current = null;
|
||||
isUserDisconnecting.current = false; // 重置主动断开标志
|
||||
}, []);
|
||||
|
||||
// 创建 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;
|
||||
|
||||
// 设置轨道接收处理(对于接收方)
|
||||
pc.ontrack = (event) => {
|
||||
console.log('[ConnectionCore] 🎥 PeerConnection收到轨道:', event.track.kind, event.track.id, '状态:', event.track.readyState);
|
||||
console.log('[ConnectionCore] 关联的流数量:', event.streams.length);
|
||||
|
||||
// 这里不处理轨道,让业务逻辑的onTrack处理器处理
|
||||
// 业务逻辑会在useEffect中设置自己的处理器
|
||||
// 这样可以确保重新连接时轨道能够被正确处理
|
||||
};
|
||||
|
||||
// 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 });
|
||||
break;
|
||||
case 'connected':
|
||||
console.log('[ConnectionCore] 🎉 WebRTC P2P连接已完全建立,可以进行媒体传输');
|
||||
// 确保所有连接状态都正确更新
|
||||
stateManager.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnected: true,
|
||||
isPeerConnected: true,
|
||||
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, canRetry: true });
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('[ConnectionCore] 🔌 WebRTC连接已断开');
|
||||
stateManager.updateState({ isPeerConnected: false });
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('[ConnectionCore] 🚫 WebRTC连接已关闭');
|
||||
stateManager.updateState({ isPeerConnected: false });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 创建数据通道
|
||||
dataChannelManager.createDataChannel(pc, role, isReconnect);
|
||||
|
||||
console.log('[ConnectionCore] ✅ PeerConnection创建完成,角色:', role, '是否重新连接:', isReconnect);
|
||||
return pc;
|
||||
}, [stateManager, dataChannelManager]);
|
||||
|
||||
// 连接到房间
|
||||
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
|
||||
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 {
|
||||
// 连接 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('[ConnectionCore] 🌐 连接WebSocket:', wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
// 保存重新连接状态,供后续使用
|
||||
const reconnectState = { isReconnect, role };
|
||||
|
||||
// WebSocket 事件处理
|
||||
ws.onopen = () => {
|
||||
console.log('[ConnectionCore] ✅ WebSocket 连接已建立,房间准备就绪');
|
||||
stateManager.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnecting: false, // WebSocket连接成功即表示初始连接完成
|
||||
isConnected: true // 可以开始后续操作
|
||||
});
|
||||
|
||||
// 如果是重新连接且是发送方,检查是否有接收方在等待
|
||||
if (reconnectState.isReconnect && reconnectState.role === 'sender') {
|
||||
console.log('[ConnectionCore] 🔄 发送方重新连接,检查是否有接收方在等待');
|
||||
// 这里不需要立即创建PeerConnection,等待接收方加入的通知
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
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,
|
||||
isPeerConnected: true // 标记对方已加入,可以开始P2P
|
||||
});
|
||||
|
||||
// 如果是重新连接,先清理旧的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 trackManager.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,
|
||||
isPeerConnected: 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;
|
||||
// 如果状态是stable,可能是因为之前的offer已经完成,需要重新创建offer
|
||||
if (signalingState === 'stable') {
|
||||
console.log('[ConnectionCore] 🔄 PeerConnection状态为stable,重新创建offer');
|
||||
try {
|
||||
await trackManager.createOffer(pcAnswer, ws);
|
||||
// 等待一段时间让ICE候选收集完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 现在状态应该是have-local-offer,可以处理answer
|
||||
if (pcAnswer.signalingState === 'have-local-offer') {
|
||||
await pcAnswer.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[ConnectionCore] ✅ answer 处理完成');
|
||||
} else {
|
||||
console.warn('[ConnectionCore] ⚠️ 重新创建offer后状态仍然不是have-local-offer:', pcAnswer.signalingState);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ConnectionCore] ❌ 重新创建offer失败:', error);
|
||||
}
|
||||
} else if (signalingState === 'have-local-offer') {
|
||||
await pcAnswer.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[ConnectionCore] ✅ answer 处理完成');
|
||||
} else {
|
||||
console.warn('[ConnectionCore] ⚠️ PeerConnection状态异常:', signalingState);
|
||||
}
|
||||
}
|
||||
} 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':
|
||||
console.error('[ConnectionCore] ❌ 信令服务器错误:', message.error);
|
||||
stateManager.updateState({ error: message.error, isConnecting: false, canRetry: true });
|
||||
break;
|
||||
|
||||
case 'disconnection':
|
||||
console.log('[ConnectionCore] 🔌 对方主动断开连接');
|
||||
// 对方断开连接的处理
|
||||
stateManager.updateState({
|
||||
isPeerConnected: false,
|
||||
isConnected: false, // 添加这个状态
|
||||
error: '对方已离开房间',
|
||||
canRetry: true
|
||||
});
|
||||
// 清理P2P连接但保持WebSocket连接,允许重新连接
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[ConnectionCore] ⚠️ 未知消息类型:', message.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ConnectionCore] ❌ 处理信令消息失败:', error);
|
||||
stateManager.updateState({ error: '信令处理失败: ' + error, isConnecting: false, canRetry: true });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[ConnectionCore] ❌ WebSocket 错误:', error);
|
||||
stateManager.updateState({ error: 'WebSocket连接失败', isConnecting: false, canRetry: true });
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
} 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;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
retry,
|
||||
getPeerConnection,
|
||||
getWebSocket,
|
||||
getCurrentRoom,
|
||||
};
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useWebRTCStore } from '../ui/webRTCStore';
|
||||
|
||||
// 基础连接状态
|
||||
export interface WebRTCState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean;
|
||||
error: string | null;
|
||||
canRetry: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC 状态管理器
|
||||
* 负责连接状态的统一管理
|
||||
*/
|
||||
export interface WebRTCStateManager {
|
||||
// 获取当前状态
|
||||
getState: () => WebRTCState;
|
||||
|
||||
// 更新状态
|
||||
updateState: (updates: Partial<WebRTCState>) => void;
|
||||
|
||||
// 设置当前房间
|
||||
setCurrentRoom: (room: { code: string; role: 'sender' | 'receiver' } | null) => void;
|
||||
|
||||
// 重置到初始状态
|
||||
resetToInitial: () => void;
|
||||
|
||||
// 检查是否已连接到指定房间
|
||||
isConnectedToRoom: (roomCode: string, role: 'sender' | 'receiver') => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC 状态管理 Hook
|
||||
* 封装对 webRTCStore 的操作,提供状态更新和查询的统一接口
|
||||
*/
|
||||
export function useWebRTCStateManager(): WebRTCStateManager {
|
||||
const webrtcStore = useWebRTCStore();
|
||||
|
||||
const getState = useCallback((): WebRTCState => {
|
||||
return {
|
||||
isConnected: webrtcStore.isConnected,
|
||||
isConnecting: webrtcStore.isConnecting,
|
||||
isWebSocketConnected: webrtcStore.isWebSocketConnected,
|
||||
isPeerConnected: webrtcStore.isPeerConnected,
|
||||
error: webrtcStore.error,
|
||||
canRetry: webrtcStore.canRetry,
|
||||
};
|
||||
}, [webrtcStore]);
|
||||
|
||||
const updateState = useCallback((updates: Partial<WebRTCState>) => {
|
||||
webrtcStore.updateState(updates);
|
||||
}, [webrtcStore]);
|
||||
|
||||
const setCurrentRoom = useCallback((room: { code: string; role: 'sender' | 'receiver' } | null) => {
|
||||
webrtcStore.setCurrentRoom(room);
|
||||
}, [webrtcStore]);
|
||||
|
||||
const resetToInitial = useCallback(() => {
|
||||
webrtcStore.resetToInitial();
|
||||
}, [webrtcStore]);
|
||||
|
||||
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
return webrtcStore.currentRoom?.code === roomCode &&
|
||||
webrtcStore.currentRoom?.role === role &&
|
||||
webrtcStore.isConnected;
|
||||
}, [webrtcStore]);
|
||||
|
||||
return {
|
||||
getState,
|
||||
updateState,
|
||||
setCurrentRoom,
|
||||
resetToInitial,
|
||||
isConnectedToRoom,
|
||||
};
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { WebRTCStateManager } from './useWebRTCStateManager';
|
||||
|
||||
/**
|
||||
* WebRTC 媒体轨道管理器
|
||||
* 负责媒体轨道的添加和移除
|
||||
*/
|
||||
export interface WebRTCTrackManager {
|
||||
// 添加媒体轨道
|
||||
addTrack: (track: MediaStreamTrack, stream: MediaStream) => RTCRtpSender | null;
|
||||
|
||||
// 移除媒体轨道
|
||||
removeTrack: (sender: RTCRtpSender) => void;
|
||||
|
||||
// 设置轨道处理器
|
||||
onTrack: (handler: (event: RTCTrackEvent) => void) => void;
|
||||
|
||||
// 创建 Offer
|
||||
createOffer: (pc: RTCPeerConnection, ws: WebSocket) => Promise<void>;
|
||||
|
||||
// 立即创建offer(用于媒体轨道添加后的重新协商)
|
||||
createOfferNow: (pc: RTCPeerConnection, ws: WebSocket) => Promise<boolean>;
|
||||
|
||||
// 内部方法,供核心连接管理器调用
|
||||
setPeerConnection: (pc: RTCPeerConnection | null) => void;
|
||||
setWebSocket: (ws: WebSocket | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC 媒体轨道管理 Hook
|
||||
* 负责媒体轨道的添加和移除,处理轨道事件,提供 createOffer 功能
|
||||
*/
|
||||
export function useWebRTCTrackManager(
|
||||
stateManager: WebRTCStateManager
|
||||
): WebRTCTrackManager {
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// 创建 Offer
|
||||
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
|
||||
try {
|
||||
console.log('[TrackManager] 🎬 开始创建offer,当前轨道数量:', pc.getSenders().length);
|
||||
|
||||
// 确保连接状态稳定
|
||||
if (pc.connectionState !== 'connecting' && pc.connectionState !== 'new') {
|
||||
console.warn('[TrackManager] ⚠️ PeerConnection状态异常:', pc.connectionState);
|
||||
}
|
||||
|
||||
const offer = await pc.createOffer({
|
||||
offerToReceiveAudio: true, // 改为true以支持音频接收
|
||||
offerToReceiveVideo: true, // 改为true以支持视频接收
|
||||
});
|
||||
|
||||
console.log('[TrackManager] 📝 Offer创建成功,设置本地描述...');
|
||||
await pc.setLocalDescription(offer);
|
||||
console.log('[TrackManager] ✅ 本地描述设置完成');
|
||||
|
||||
// 增加超时时间到5秒,给ICE候选收集更多时间
|
||||
const iceTimeout = setTimeout(() => {
|
||||
console.log('[TrackManager] ⏱️ ICE收集超时,发送当前offer');
|
||||
if (ws.readyState === WebSocket.OPEN && pc.localDescription) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[TrackManager] 📤 发送 offer (超时发送)');
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 如果ICE收集已经完成,立即发送
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN && pc.localDescription) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[TrackManager] 📤 发送 offer (ICE收集完成)');
|
||||
}
|
||||
} else {
|
||||
console.log('[TrackManager] 🧊 等待ICE候选收集...');
|
||||
// 监听ICE收集状态变化
|
||||
pc.onicegatheringstatechange = () => {
|
||||
console.log('[TrackManager] 🧊 ICE收集状态变化:', pc.iceGatheringState);
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN && pc.localDescription) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[TrackManager] 📤 发送 offer (ICE收集完成)');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 同时监听ICE候选事件,用于调试
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log('[TrackManager] 🧊 收到ICE候选:', event.candidate.candidate.substring(0, 50) + '...');
|
||||
} else {
|
||||
console.log('[TrackManager] 🏁 ICE候选收集完成');
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TrackManager] ❌ 创建 offer 失败:', error);
|
||||
stateManager.updateState({ error: '创建连接失败', isConnecting: false, canRetry: true });
|
||||
}
|
||||
}, [stateManager]);
|
||||
|
||||
// 添加媒体轨道
|
||||
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');
|
||||
// 检查WebSocket连接状态,只有连接后才尝试设置
|
||||
const state = stateManager.getState();
|
||||
if (!state.isWebSocketConnected) {
|
||||
console.log('[TrackManager] WebSocket未连接,等待连接建立...');
|
||||
return;
|
||||
}
|
||||
|
||||
// 延迟设置,等待PeerConnection准备就绪
|
||||
let retryCount = 0;
|
||||
const maxRetries = 50; // 增加重试次数到50次,即5秒
|
||||
|
||||
const checkAndSetTrackHandler = () => {
|
||||
const currentPc = pcRef.current;
|
||||
if (currentPc) {
|
||||
console.log('[TrackManager] ✅ PeerConnection 已准备就绪,设置onTrack处理器');
|
||||
currentPc.ontrack = handler;
|
||||
|
||||
// 如果已经有远程轨道,立即触发处理
|
||||
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 长时间未准备就绪,停止重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
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]);
|
||||
|
||||
// 立即创建offer(用于媒体轨道添加后的重新协商)
|
||||
const createOfferNow = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
|
||||
if (!pc || !ws) {
|
||||
console.error('[TrackManager] PeerConnection 或 WebSocket 不可用');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await createOffer(pc, ws);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[TrackManager] 创建 offer 失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, [createOffer]);
|
||||
|
||||
// 设置 PeerConnection 引用
|
||||
const setPeerConnection = useCallback((pc: RTCPeerConnection | null) => {
|
||||
pcRef.current = pc;
|
||||
}, []);
|
||||
|
||||
// 设置 WebSocket 引用
|
||||
const setWebSocket = useCallback((ws: WebSocket | null) => {
|
||||
wsRef.current = ws;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
addTrack,
|
||||
removeTrack,
|
||||
onTrack,
|
||||
createOffer,
|
||||
createOfferNow,
|
||||
// 内部方法,供核心连接管理器调用
|
||||
setPeerConnection,
|
||||
setWebSocket,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
|
||||
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 {
|
||||
// 清理所有没有轨道的发送器(避免空 sender 干扰)
|
||||
const allSenders = pc.getSenders();
|
||||
console.log('[ConnectionCore] 🎬 开始创建offer,总发送器数量:', allSenders.length);
|
||||
|
||||
// 移除所有 track 为 null 的 sender
|
||||
const emptyRemovals = allSenders.filter(sender => !sender.track).map(async sender => {
|
||||
try {
|
||||
await pc.removeTrack(sender);
|
||||
console.log('[ConnectionCore] 🗑️ 已清理空发送器');
|
||||
} catch (e) {
|
||||
console.warn('[ConnectionCore] ⚠️ 清理空发送器失败:', e);
|
||||
}
|
||||
});
|
||||
|
||||
if (emptyRemovals.length > 0) {
|
||||
await Promise.all(emptyRemovals);
|
||||
console.log('[ConnectionCore] 🧹 已清理', emptyRemovals.length, '个空发送器');
|
||||
}
|
||||
|
||||
// 获取清理后的有效发送器
|
||||
const activeSenders = pc.getSenders().filter(s => s.track);
|
||||
console.log('[ConnectionCore] 📊 有效轨道数量:', activeSenders.length);
|
||||
activeSenders.forEach((sender, index) => {
|
||||
console.log(`[ConnectionCore] 发送器 ${index}:`, {
|
||||
kind: sender.track?.kind,
|
||||
id: sender.track?.id,
|
||||
enabled: sender.track?.enabled,
|
||||
readyState: sender.track?.readyState
|
||||
});
|
||||
});
|
||||
|
||||
// 确保连接状态稳定
|
||||
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收到远程轨道:', {
|
||||
kind: event.track.kind,
|
||||
id: event.track.id,
|
||||
enabled: event.track.enabled,
|
||||
readyState: event.track.readyState,
|
||||
streamCount: event.streams.length,
|
||||
streamId: event.streams[0]?.id
|
||||
});
|
||||
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) {
|
||||
const currentState = pcOffer.signalingState;
|
||||
console.log('[ConnectionCore] 当前信令状态:', currentState, '角色:', role);
|
||||
|
||||
// Perfect Negotiation 模式:receiver 是 polite,sender 是 impolite
|
||||
const isPolite = role === 'receiver';
|
||||
|
||||
// 处理交叉协商
|
||||
if (currentState === 'have-local-offer') {
|
||||
if (isPolite) {
|
||||
// Polite peer (receiver) 回滚自己的 offer
|
||||
console.log('[ConnectionCore] 🔄 [Polite-Receiver] 交叉协商,回滚本地 offer');
|
||||
await pcOffer.setLocalDescription({ type: 'rollback' });
|
||||
} else {
|
||||
// Impolite peer (sender) 也需要接受对方的 offer!
|
||||
// 之前的逻辑错误:不应该直接 break,而是也要回滚或等待
|
||||
console.log('[ConnectionCore] 🔄 [Impolite-Sender] 交叉协商,回滚并接受对方 offer');
|
||||
await pcOffer.setLocalDescription({ type: 'rollback' });
|
||||
}
|
||||
}
|
||||
|
||||
await pcOffer.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[ConnectionCore] ✅ 设置远程描述完成');
|
||||
|
||||
// 记录当前本地轨道
|
||||
const currentSenders = pcOffer.getSenders();
|
||||
console.log('[ConnectionCore] 📊 创建 answer 前的本地轨道数量:', currentSenders.length);
|
||||
currentSenders.forEach((sender, index) => {
|
||||
console.log(`[ConnectionCore] 本地发送器 ${index}:`, {
|
||||
kind: sender.track?.kind,
|
||||
id: sender.track?.id,
|
||||
enabled: sender.track?.enabled
|
||||
});
|
||||
});
|
||||
|
||||
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不存在');
|
||||
}
|
||||
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);
|
||||
|
||||
if (signalingState === 'have-local-offer') {
|
||||
// 正常的answer处理
|
||||
await pcAnswer.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[ConnectionCore] ✅ answer 处理完成');
|
||||
} else {
|
||||
console.warn('[ConnectionCore] ⚠️ PeerConnection状态不是have-local-offer:', 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 'renegotiate-request':
|
||||
// 接收方请求重新协商(例如添加/移除音频轨道)
|
||||
console.log('[ConnectionCore] 🔄 收到重新协商请求:', message.payload);
|
||||
if (role === 'sender') {
|
||||
// 只有发送方才能响应重新协商请求
|
||||
const pcRenegotiate = pcRef.current;
|
||||
if (pcRenegotiate) {
|
||||
console.log('[ConnectionCore] 📡 [Sender] 响应重新协商请求,创建新的 offer');
|
||||
try {
|
||||
await createOffer(pcRenegotiate, ws);
|
||||
console.log('[ConnectionCore] ✅ [Sender] 重新协商 offer 发送完成');
|
||||
} catch (error) {
|
||||
console.error('[ConnectionCore] ❌ [Sender] 重新协商失败:', error);
|
||||
}
|
||||
} else {
|
||||
console.warn('[ConnectionCore] ⚠️ [Sender] PeerConnection 不存在,无法重新协商');
|
||||
}
|
||||
} else {
|
||||
console.warn('[ConnectionCore] ⚠️ [Receiver] 收到重新协商请求但角色不是 sender');
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,65 +1,33 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { WebRTCStateManager } from './useWebRTCStateManager';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { IWebConnectStateManager } from '../state/useWebConnectStateManager';
|
||||
import { DataHandler, IRegisterEventHandler, IWebMessage, MessageHandler, Role, WebRTCDataChannelManager } from '../types';
|
||||
|
||||
// 消息类型
|
||||
export interface WebRTCMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
// 消息和数据处理器类型
|
||||
export type MessageHandler = (message: WebRTCMessage) => void;
|
||||
export type DataHandler = (data: ArrayBuffer) => void;
|
||||
|
||||
/**
|
||||
* WebRTC 数据通道管理器
|
||||
* 负责数据通道的创建和管理
|
||||
*/
|
||||
export interface WebRTCDataChannelManager {
|
||||
// 创建数据通道
|
||||
createDataChannel: (pc: RTCPeerConnection, role: 'sender' | 'receiver', isReconnect?: boolean) => 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;
|
||||
|
||||
// 处理数据通道消息
|
||||
handleDataChannelMessage: (event: MessageEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC 数据通道管理 Hook
|
||||
* 负责数据通道的创建和管理,处理数据通道消息的发送和接收
|
||||
*/
|
||||
export function useWebRTCDataChannelManager(
|
||||
stateManager: WebRTCStateManager
|
||||
): WebRTCDataChannelManager {
|
||||
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: 'sender' | 'receiver',
|
||||
pc: RTCPeerConnection,
|
||||
role: Role,
|
||||
isReconnect: boolean = false
|
||||
) => {
|
||||
console.log('[DataChannelManager] 创建数据通道...', { role, isReconnect });
|
||||
|
||||
|
||||
// 如果已经存在数据通道,先关闭它
|
||||
if (dcRef.current) {
|
||||
console.log('[DataChannelManager] 关闭已存在的数据通道');
|
||||
@@ -75,18 +43,20 @@ export function useWebRTCDataChannelManager(
|
||||
});
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
dataChannel.onopen = (event) => {
|
||||
console.log('[DataChannelManager] 数据通道已打开 (发送方)');
|
||||
// 确保所有连接状态都正确更新
|
||||
stateManager.updateState({
|
||||
isWebSocketConnected: true,
|
||||
stateManagerRef.current.updateState({
|
||||
isDataChannelConnected: true,
|
||||
isConnected: true,
|
||||
isPeerConnected: true,
|
||||
error: null,
|
||||
isConnecting: false,
|
||||
canRetry: false
|
||||
canRetry: false,
|
||||
state: 'open',
|
||||
stateMsg: "数据通道已打开"
|
||||
});
|
||||
|
||||
|
||||
// 如果是重新连接,触发数据同步
|
||||
if (isReconnect) {
|
||||
console.log('[DataChannelManager] 发送方重新连接,数据通道已打开,准备同步数据');
|
||||
@@ -102,17 +72,17 @@ export function useWebRTCDataChannelManager(
|
||||
}, 300); // 等待数据通道完全稳定
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[DataChannelManager] 数据通道错误:', error);
|
||||
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '数据通道连接失败';
|
||||
let shouldRetry = false;
|
||||
|
||||
|
||||
// 根据数据通道状态提供更具体的错误信息
|
||||
switch (dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
@@ -144,13 +114,14 @@ export function useWebRTCDataChannelManager(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.error(`[DataChannelManager] 数据通道详细错误 - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
|
||||
|
||||
stateManager.updateState({
|
||||
|
||||
stateManagerRef.current.updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isPeerConnected: false, // 数据通道出错时,P2P连接肯定不可用
|
||||
isDataChannelConnected: false,
|
||||
canRetry: shouldRetry // 设置是否可以重试
|
||||
});
|
||||
};
|
||||
@@ -159,18 +130,20 @@ export function useWebRTCDataChannelManager(
|
||||
const dataChannel = event.channel;
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
dataChannel.onopen = (event) => {
|
||||
console.log('[DataChannelManager] 数据通道已打开 (接收方)');
|
||||
// 确保所有连接状态都正确更新
|
||||
stateManager.updateState({
|
||||
stateManagerRef.current.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isDataChannelConnected: true,
|
||||
isConnected: true,
|
||||
isPeerConnected: true,
|
||||
error: null,
|
||||
isConnecting: false,
|
||||
canRetry: false
|
||||
canRetry: false,
|
||||
state: 'open'
|
||||
});
|
||||
|
||||
|
||||
// 如果是重新连接,触发数据同步
|
||||
if (isReconnect) {
|
||||
console.log('[DataChannelManager] 接收方重新连接,数据通道已打开,准备同步数据');
|
||||
@@ -191,11 +164,11 @@ export function useWebRTCDataChannelManager(
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[DataChannelManager] 数据通道错误 (接收方):', error);
|
||||
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '数据通道连接失败';
|
||||
let shouldRetry = false;
|
||||
|
||||
|
||||
// 根据数据通道状态提供更具体的错误信息
|
||||
switch (dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
@@ -227,13 +200,14 @@ export function useWebRTCDataChannelManager(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.error(`[DataChannelManager] 数据通道详细错误 (接收方) - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
|
||||
|
||||
stateManager.updateState({
|
||||
|
||||
stateManagerRef.current.updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isPeerConnected: false, // 数据通道出错时,P2P连接肯定不可用
|
||||
isDataChannelConnected: false,
|
||||
canRetry: shouldRetry // 设置是否可以重试
|
||||
});
|
||||
};
|
||||
@@ -245,9 +219,11 @@ export function useWebRTCDataChannelManager(
|
||||
|
||||
// 处理数据通道消息
|
||||
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 WebRTCMessage;
|
||||
const message = JSON.parse(event.data) as IWebMessage;
|
||||
console.log('[DataChannelManager] 收到消息:', message.type, message.channel || 'default');
|
||||
|
||||
// 根据通道分发消息
|
||||
@@ -280,8 +256,29 @@ export function useWebRTCDataChannelManager(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 注册消息处理器
|
||||
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: WebRTCMessage, channel?: string) => {
|
||||
const sendMessage = useCallback((message: IWebMessage, channel?: string) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('[DataChannelManager] 数据通道未准备就绪');
|
||||
@@ -317,40 +314,71 @@ export function useWebRTCDataChannelManager(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 注册消息处理器
|
||||
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 getChannelState = useCallback(() => {
|
||||
return dcRef.current?.readyState || 'closed';
|
||||
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,
|
||||
registerMessageHandler,
|
||||
getConnectState: getChannelState,
|
||||
registerDataHandler,
|
||||
getChannelState,
|
||||
registerMessageHandler,
|
||||
handleDataChannelMessage,
|
||||
};
|
||||
}
|
||||
196
chuan-next/src/hooks/connection/webrtc/useWebRTCTrackManager.ts
Normal file
196
chuan-next/src/hooks/connection/webrtc/useWebRTCTrackManager.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
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 {
|
||||
// 触发重新协商事件(应该由 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 trackHandlersRef = useRef<Set<(event: RTCTrackEvent) => void>>(new Set());
|
||||
|
||||
// 设置轨道处理器 - 返回清理函数
|
||||
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void): (() => void) => {
|
||||
// 添加到处理器集合
|
||||
trackHandlersRef.current.add(handler);
|
||||
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
// 检查是否已有重试在进行,避免多个重试循环
|
||||
if (retryInProgressRef.current) {
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
trackHandlersRef.current.delete(handler);
|
||||
console.log('[TrackManager] 🗑️ 移除轨道处理器,剩余处理器数量:', trackHandlersRef.current.size);
|
||||
};
|
||||
}
|
||||
|
||||
// 检查WebSocket连接状态,只有连接后才尝试设置
|
||||
const state = stateManager.getState();
|
||||
if (!state.isWebSocketConnected) {
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
trackHandlersRef.current.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
retryInProgressRef.current = true;
|
||||
|
||||
// 延迟设置,等待PeerConnection准备就绪
|
||||
let retryCount = 0;
|
||||
const maxRetries = 20; // 减少重试次数到20次,即2秒
|
||||
|
||||
const checkAndSetTrackHandler = () => {
|
||||
const currentPc = pcRef.current;
|
||||
if (currentPc) {
|
||||
// 设置多路复用处理器
|
||||
currentPc.ontrack = (event: RTCTrackEvent) => {
|
||||
trackHandlersRef.current.forEach(h => {
|
||||
try {
|
||||
h(event);
|
||||
} catch (error) {
|
||||
console.error('[TrackManager] 轨道处理器执行错误:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
retryInProgressRef.current = false;
|
||||
} else {
|
||||
retryCount++;
|
||||
if (retryCount < maxRetries) {
|
||||
setTimeout(checkAndSetTrackHandler, 100);
|
||||
} else {
|
||||
console.error('[TrackManager] ❌ PeerConnection 长时间未准备就绪,停止重试');
|
||||
retryInProgressRef.current = false; // 失败后也要重置标记
|
||||
}
|
||||
}
|
||||
};
|
||||
checkAndSetTrackHandler();
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
trackHandlersRef.current.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
// 设置多路复用处理器
|
||||
pc.ontrack = (event: RTCTrackEvent) => {
|
||||
trackHandlersRef.current.forEach(h => {
|
||||
try {
|
||||
h(event);
|
||||
} catch (error) {
|
||||
console.error('[TrackManager] 轨道处理器执行错误:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
trackHandlersRef.current.delete(handler);
|
||||
};
|
||||
}, [stateManager]);
|
||||
|
||||
// 立即触发重新协商(用于媒体轨道添加后的重新协商)
|
||||
const triggerRenegotiation = useCallback(async () => {
|
||||
const pc = pcRef.current;
|
||||
const ws = wsRef.current;
|
||||
|
||||
if (!pc || !ws) {
|
||||
console.error('[TrackManager] PeerConnection 或 WebSocket 不可用');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 实际的 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,
|
||||
};
|
||||
}
|
||||
2
chuan-next/src/hooks/connection/ws/index.ts
Normal file
2
chuan-next/src/hooks/connection/ws/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// WebSocket 连接相关导出
|
||||
export { useWebSocketConnection } from './useWebSocketConnection';
|
||||
452
chuan-next/src/hooks/connection/ws/useWebSocketConnection.ts
Normal file
452
chuan-next/src/hooks/connection/ws/useWebSocketConnection.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
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, // 欺骗 UI,WebSocket 也能传输数据
|
||||
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((callback: (event: RTCTrackEvent) => void): (() => void) => {
|
||||
console.warn('[WebSocket] WebSocket 不支持媒体轨道');
|
||||
// 返回清理函数以符合接口预期
|
||||
return () => {};
|
||||
}, []);
|
||||
|
||||
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, // 欺骗 UI,WebSocket 也能传输数据
|
||||
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, // 欺骗 UI,WebSocket 也能传输数据
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
// 桌面共享相关的hooks
|
||||
export { useDesktopShareBusiness } from './useDesktopShareBusiness';
|
||||
export { useVoiceChatBusiness } from './useVoiceChatBusiness';
|
||||
export { useAudioVisualizer } from './useAudioVisualizer';
|
||||
|
||||
122
chuan-next/src/hooks/desktop-share/useAudioVisualizer.ts
Normal file
122
chuan-next/src/hooks/desktop-share/useAudioVisualizer.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface AudioVisualizerState {
|
||||
volume: number; // 0-100
|
||||
isSpeaking: boolean;
|
||||
}
|
||||
|
||||
export function useAudioVisualizer(stream: MediaStream | null) {
|
||||
const [state, setState] = useState<AudioVisualizerState>({
|
||||
volume: 0,
|
||||
isSpeaking: false,
|
||||
});
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream) {
|
||||
// 清理状态
|
||||
setState({ volume: 0, isSpeaking: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建音频上下文
|
||||
const audioContext = new AudioContext();
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
// 创建分析器节点
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
// 连接音频流到分析器
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
source.connect(analyser);
|
||||
|
||||
// 创建数据数组
|
||||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||
|
||||
// 音量检测阈值
|
||||
const SPEAKING_THRESHOLD = 10; // 说话阈值
|
||||
const SILENCE_FRAMES = 10; // 连续多少帧低于阈值才认为停止说话
|
||||
let silenceFrameCount = 0;
|
||||
|
||||
// 分析音频数据
|
||||
const analyzeAudio = () => {
|
||||
if (!analyserRef.current) return;
|
||||
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
// 计算平均音量
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
sum += dataArray[i];
|
||||
}
|
||||
const average = sum / dataArray.length;
|
||||
|
||||
// 归一化到 0-100
|
||||
const normalizedVolume = Math.min(100, Math.round((average / 255) * 100));
|
||||
|
||||
// 判断是否在说话
|
||||
const currentlySpeaking = normalizedVolume > SPEAKING_THRESHOLD;
|
||||
|
||||
if (currentlySpeaking) {
|
||||
silenceFrameCount = 0;
|
||||
setState(prev => ({
|
||||
volume: normalizedVolume,
|
||||
isSpeaking: true,
|
||||
}));
|
||||
} else {
|
||||
silenceFrameCount++;
|
||||
if (silenceFrameCount >= SILENCE_FRAMES) {
|
||||
setState(prev => ({
|
||||
volume: normalizedVolume,
|
||||
isSpeaking: false,
|
||||
}));
|
||||
} else {
|
||||
// 保持说话状态,但更新音量
|
||||
setState(prev => ({
|
||||
volume: normalizedVolume,
|
||||
isSpeaking: prev.isSpeaking,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(analyzeAudio);
|
||||
};
|
||||
|
||||
// 开始分析
|
||||
analyzeAudio();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
analyserRef.current = null;
|
||||
setState({ volume: 0, isSpeaking: false });
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AudioVisualizer] 初始化音频分析器失败:', error);
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -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,59 +34,90 @@ 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, '状态:', event.track.readyState);
|
||||
console.log('[DesktopShare] 远程流数量:', event.streams.length);
|
||||
const handleRemoteTrack = useCallback((event: RTCTrackEvent) => {
|
||||
// 只处理视频轨道,音频轨道由 useVoiceChatBusiness 处理
|
||||
if (event.track.kind !== 'video') {
|
||||
console.log('[DesktopShare] ⏭️ 跳过非视频轨道:', event.track.kind, event.track.id);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DesktopShare] 🎥 收到远程视频轨道:', 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);
|
||||
|
||||
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);
|
||||
});
|
||||
// 只提取视频轨道创建新的视频流
|
||||
const videoTracks = remoteStream.getVideoTracks();
|
||||
if (videoTracks.length > 0) {
|
||||
const videoStream = new MediaStream(videoTracks);
|
||||
console.log('[DesktopShare] 📹 创建纯视频流,视频轨道数:', videoTracks.length);
|
||||
|
||||
// 确保轨道已启用
|
||||
remoteStream.getTracks().forEach(track => {
|
||||
videoTracks.forEach(track => {
|
||||
console.log('[DesktopShare] 视频轨道:', track.id, '启用:', track.enabled, '状态:', track.readyState);
|
||||
// 确保轨道已启用
|
||||
if (!track.enabled) {
|
||||
console.log('[DesktopShare] 🔓 启用远程轨道:', track.id);
|
||||
console.log('[DesktopShare] 🔓 启用视频轨道:', track.id);
|
||||
track.enabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
handleRemoteStream(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;
|
||||
}
|
||||
});
|
||||
|
||||
handleRemoteStream(newStream);
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] ❌ 从轨道创建流失败:', error);
|
||||
|
||||
// 直接使用setState
|
||||
setState(prev => ({ ...prev, remoteStream: videoStream }));
|
||||
|
||||
// 如果有视频元素引用,设置流
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = videoStream;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [webRTC, handleRemoteStream]);
|
||||
} else {
|
||||
console.warn('[DesktopShare] ⚠️ 收到视频轨道但没有关联的流');
|
||||
// 尝试从轨道创建流
|
||||
try {
|
||||
const newStream = new MediaStream([event.track]);
|
||||
console.log('[DesktopShare] 🔄 从视频轨道创建新流:', newStream.id);
|
||||
|
||||
// 确保轨道已启用
|
||||
if (!event.track.enabled) {
|
||||
console.log('[DesktopShare] 🔓 启用视频轨道:', event.track.id);
|
||||
event.track.enabled = true;
|
||||
}
|
||||
|
||||
// 直接使用setState
|
||||
setState(prev => ({ ...prev, remoteStream: newStream }));
|
||||
|
||||
// 如果有视频元素引用,设置流
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = newStream;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] ❌ 从轨道创建流失败:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webRTC) return;
|
||||
|
||||
const cleanup = webRTC.onTrack(handleRemoteTrack);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}, [webRTC, handleRemoteTrack]); // 依赖 webRTC 和稳定的处理器函数
|
||||
|
||||
// 获取桌面共享流
|
||||
const getDesktopStream = useCallback(async (): Promise<MediaStream> => {
|
||||
@@ -112,27 +145,27 @@ export function useDesktopShareBusiness() {
|
||||
// 设置视频轨道发送
|
||||
const setupVideoSending = useCallback(async (stream: MediaStream) => {
|
||||
console.log('[DesktopShare] 🎬 开始设置视频轨道发送...');
|
||||
|
||||
|
||||
// 检查P2P连接状态
|
||||
if (!webRTC.isPeerConnected) {
|
||||
if (!webRTC.getConnectState().isPeerConnected) {
|
||||
console.warn('[DesktopShare] ⚠️ P2P连接尚未完全建立,等待连接稳定...');
|
||||
// 等待连接稳定
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
|
||||
// 再次检查
|
||||
if (!webRTC.isPeerConnected) {
|
||||
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];
|
||||
@@ -169,7 +202,7 @@ export function useDesktopShareBusiness() {
|
||||
|
||||
// 轨道添加完成,现在需要重新协商以包含媒体轨道
|
||||
console.log('[DesktopShare] ✅ 桌面共享轨道添加完成,开始重新协商');
|
||||
|
||||
|
||||
// 获取PeerConnection实例以便调试
|
||||
const pc = webRTC.getPeerConnection();
|
||||
if (pc) {
|
||||
@@ -180,20 +213,20 @@ export function useDesktopShareBusiness() {
|
||||
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] 🔍 重新协商后连接状态:', {
|
||||
@@ -233,7 +266,7 @@ export function useDesktopShareBusiness() {
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '创建房间失败');
|
||||
}
|
||||
@@ -241,10 +274,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();
|
||||
@@ -255,104 +288,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 {
|
||||
// 检查P2P连接状态(与switchDesktop保持一致)
|
||||
if (!webRTC.isPeerConnected) {
|
||||
throw new Error('P2P连接未建立');
|
||||
}
|
||||
|
||||
updateState({ error: null });
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
console.log('[DesktopShare] 📺 正在请求桌面共享权限...');
|
||||
|
||||
// 获取桌面流
|
||||
const stream = await getDesktopStream();
|
||||
|
||||
// 停止之前的流(如果有)- 与switchDesktop保持一致
|
||||
|
||||
// 停止之前的流(如果有)
|
||||
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);
|
||||
|
||||
// 设置新的视频发送 - 与switchDesktop保持一致
|
||||
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]); // 移除setupVideoSending和updateState依赖
|
||||
|
||||
// 切换桌面共享(重新选择屏幕)
|
||||
const switchDesktop = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (!webRTC.isPeerConnected) {
|
||||
throw new Error('P2P连接未建立');
|
||||
}
|
||||
|
||||
if (!state.isSharing) {
|
||||
throw new Error('当前未在共享桌面');
|
||||
}
|
||||
|
||||
updateState({ error: null });
|
||||
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]); // 移除setupVideoSending和updateState依赖
|
||||
|
||||
// 停止桌面共享
|
||||
const stopSharing = useCallback(async (): Promise<void> => {
|
||||
@@ -377,20 +476,22 @@ 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);
|
||||
updateState({ error: errorMessage });
|
||||
setState(prev => ({ ...prev, error: errorMessage }));
|
||||
}
|
||||
}, [webRTC, updateState]);
|
||||
}, [webRTC]); // 移除updateState依赖,直接使用setState
|
||||
|
||||
// 重置桌面共享到初始状态(让用户重新选择桌面)
|
||||
const resetSharing = useCallback(async (): Promise<void> => {
|
||||
@@ -413,24 +514,85 @@ export function useDesktopShareBusiness() {
|
||||
}
|
||||
|
||||
// 保留WebSocket连接和房间代码,但重置共享状态
|
||||
updateState({
|
||||
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
|
||||
@@ -452,9 +614,9 @@ export function useDesktopShareBusiness() {
|
||||
});
|
||||
}
|
||||
|
||||
updateState({ isViewing: true });
|
||||
setState(prev => ({ ...prev, isViewing: true }));
|
||||
console.log('[DesktopShare] 👁️ 已进入桌面共享观看模式,等待接收流...');
|
||||
|
||||
|
||||
// 设置一个超时检查,如果长时间没有收到流,输出警告
|
||||
setTimeout(() => {
|
||||
if (!state.remoteStream) {
|
||||
@@ -465,10 +627,10 @@ export function useDesktopShareBusiness() {
|
||||
} 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, state.remoteStream]);
|
||||
}, [webRTC, state.remoteStream]); // 移除updateState依赖
|
||||
|
||||
// 停止观看桌面共享
|
||||
const stopViewing = useCallback(async (): Promise<void> => {
|
||||
@@ -478,19 +640,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) => {
|
||||
@@ -500,6 +663,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 () => {
|
||||
@@ -515,28 +727,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,
|
||||
};
|
||||
|
||||
317
chuan-next/src/hooks/desktop-share/useVoiceChatBusiness.ts
Normal file
317
chuan-next/src/hooks/desktop-share/useVoiceChatBusiness.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { IWebConnection } from '../connection/types';
|
||||
import { useAudioVisualizer } from './useAudioVisualizer';
|
||||
|
||||
interface VoiceChatState {
|
||||
isVoiceEnabled: boolean;
|
||||
isMuted: boolean;
|
||||
isRemoteVoiceActive: boolean;
|
||||
localAudioStream: MediaStream | null;
|
||||
remoteAudioStream: MediaStream | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useVoiceChatBusiness(connection: IWebConnection) {
|
||||
const [state, setState] = useState<VoiceChatState>({
|
||||
isVoiceEnabled: false,
|
||||
isMuted: false,
|
||||
isRemoteVoiceActive: false,
|
||||
localAudioStream: null,
|
||||
remoteAudioStream: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const localAudioStreamRef = useRef<MediaStream | null>(null);
|
||||
const audioSenderRef = useRef<RTCRtpSender | null>(null);
|
||||
const remoteAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// 使用音频可视化
|
||||
const localAudioVisualizer = useAudioVisualizer(state.localAudioStream);
|
||||
const remoteAudioVisualizer = useAudioVisualizer(state.remoteAudioStream);
|
||||
|
||||
const updateState = useCallback((updates: Partial<VoiceChatState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 监听远程音频轨道
|
||||
const handleRemoteAudioTrack = useCallback((event: RTCTrackEvent, currentTrackRef: { current: MediaStreamTrack | null }) => {
|
||||
if (event.track.kind !== 'audio') return;
|
||||
|
||||
// 移除旧轨道的监听器
|
||||
if (currentTrackRef.current) {
|
||||
currentTrackRef.current.onended = null;
|
||||
currentTrackRef.current.onmute = null;
|
||||
currentTrackRef.current.onunmute = null;
|
||||
}
|
||||
currentTrackRef.current = event.track;
|
||||
|
||||
if (event.streams.length > 0) {
|
||||
const remoteStream = event.streams[0];
|
||||
event.track.enabled = true;
|
||||
|
||||
// 更新状态
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
remoteAudioStream: remoteStream,
|
||||
isRemoteVoiceActive: true
|
||||
}));
|
||||
|
||||
// 监听轨道结束事件
|
||||
event.track.onended = () => {
|
||||
setState(prev => ({ ...prev, isRemoteVoiceActive: false }));
|
||||
};
|
||||
|
||||
// 监听轨道静音事件
|
||||
event.track.onmute = () => {
|
||||
// 远程音频轨道被静音
|
||||
};
|
||||
|
||||
event.track.onunmute = () => {
|
||||
// 远程音频轨道取消静音
|
||||
};
|
||||
|
||||
// 在设置状态后,使用 setTimeout 确保 audio 元素更新
|
||||
setTimeout(() => {
|
||||
if (remoteAudioRef.current && remoteStream.active) {
|
||||
remoteAudioRef.current.srcObject = remoteStream;
|
||||
remoteAudioRef.current.play().catch(err => {
|
||||
// 忽略 AbortError,这是正常的竞态条件
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('[VoiceChat] 播放远程音频失败:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, []); // 空依赖数组,函数引用始终不变
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
|
||||
const currentTrackRef = { current: null as MediaStreamTrack | null };
|
||||
|
||||
const trackHandler = (event: RTCTrackEvent) => {
|
||||
if (event.track.kind === 'audio') {
|
||||
handleRemoteAudioTrack(event, currentTrackRef);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = connection.onTrack(trackHandler);
|
||||
|
||||
return () => {
|
||||
if (currentTrackRef.current) {
|
||||
currentTrackRef.current.onended = null;
|
||||
currentTrackRef.current.onmute = null;
|
||||
currentTrackRef.current.onunmute = null;
|
||||
}
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}, [connection, handleRemoteAudioTrack]); // 只在 connection 或处理器变化时重新注册
|
||||
|
||||
// 获取本地音频流
|
||||
const getLocalAudioStream = useCallback(async (): Promise<MediaStream> => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
video: false,
|
||||
});
|
||||
|
||||
return stream;
|
||||
} catch (error) {
|
||||
console.error('[VoiceChat] 获取本地音频流失败:', error);
|
||||
|
||||
// 根据错误类型提供更详细的错误消息
|
||||
if (error instanceof DOMException) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
throw new Error('麦克风权限被拒绝,请在浏览器设置中允许使用麦克风');
|
||||
} else if (error.name === 'NotFoundError') {
|
||||
throw new Error('未检测到麦克风设备,请连接麦克风后重试');
|
||||
} else if (error.name === 'NotReadableError') {
|
||||
throw new Error('麦克风被其他应用占用,请关闭其他使用麦克风的程序');
|
||||
} else if (error.name === 'OverconstrainedError') {
|
||||
throw new Error('麦克风不支持所需的音频设置');
|
||||
} else if (error.name === 'AbortError') {
|
||||
throw new Error('麦克风访问被中断');
|
||||
} else if (error.name === 'SecurityError') {
|
||||
throw new Error('安全限制:无法访问麦克风(请使用HTTPS)');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('无法获取麦克风权限,请确保允许使用麦克风');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 启用语音通话
|
||||
const enableVoice = useCallback(async () => {
|
||||
if (state.isVoiceEnabled || !connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateState({ error: null });
|
||||
|
||||
// 检查P2P连接状态
|
||||
const connectState = connection.getConnectState();
|
||||
if (!connectState.isPeerConnected) {
|
||||
throw new Error('P2P连接尚未建立,无法启用语音');
|
||||
}
|
||||
|
||||
// 获取本地音频流
|
||||
const stream = await getLocalAudioStream();
|
||||
localAudioStreamRef.current = stream;
|
||||
|
||||
console.log('[VoiceChat] ✅ 本地音频流获取成功:', {
|
||||
streamId: stream.id,
|
||||
audioTracks: stream.getAudioTracks().length,
|
||||
trackEnabled: stream.getAudioTracks()[0]?.enabled,
|
||||
trackReadyState: stream.getAudioTracks()[0]?.readyState
|
||||
});
|
||||
|
||||
// 添加音频轨道到P2P连接
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
const role = connection.currentRoom?.role;
|
||||
console.log('[VoiceChat] 📤 添加音频轨道到P2P连接, 当前角色:', role);
|
||||
|
||||
const sender = connection.addTrack(audioTrack, stream);
|
||||
audioSenderRef.current = sender;
|
||||
|
||||
if (sender) {
|
||||
console.log('[VoiceChat] 📊 Sender 信息:', {
|
||||
track: sender.track?.id,
|
||||
trackEnabled: sender.track?.enabled,
|
||||
trackReadyState: sender.track?.readyState
|
||||
});
|
||||
}
|
||||
|
||||
// 重要:添加音频轨道后,本地必须主动创建 offer
|
||||
// 因为对方不知道我们添加了新轨道,必须由我们通知对方
|
||||
console.log('[VoiceChat] 📡 [' + role + '] 创建 offer 进行重新协商(添加音频轨道)');
|
||||
const negotiated = await connection.createOfferNow();
|
||||
console.log('[VoiceChat] 📡 [' + role + '] 重新协商结果:', negotiated);
|
||||
}
|
||||
|
||||
updateState({
|
||||
isVoiceEnabled: true,
|
||||
localAudioStream: stream,
|
||||
isMuted: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[VoiceChat] 启用语音失败:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : '启用语音失败';
|
||||
updateState({ error: errorMsg });
|
||||
throw error;
|
||||
}
|
||||
}, [connection, getLocalAudioStream, state.isVoiceEnabled, updateState]);
|
||||
|
||||
// 禁用语音通话
|
||||
const disableVoice = useCallback(async () => {
|
||||
if (!state.isVoiceEnabled) return;
|
||||
|
||||
const role = connection.currentRoom?.role;
|
||||
|
||||
// 移除音频轨道
|
||||
if (audioSenderRef.current) {
|
||||
connection.removeTrack(audioSenderRef.current);
|
||||
audioSenderRef.current = null;
|
||||
|
||||
// 重要:移除音频轨道后,本地必须主动创建 offer
|
||||
// 因为对方不知道我们移除了轨道,必须由我们通知对方
|
||||
console.log('[VoiceChat] 📡 [' + role + '] 移除音频轨道后重新协商');
|
||||
try {
|
||||
await connection.createOfferNow();
|
||||
} catch (error) {
|
||||
console.error('[VoiceChat] 重新协商失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 停止本地音频流
|
||||
if (localAudioStreamRef.current) {
|
||||
localAudioStreamRef.current.getTracks().forEach(track => {
|
||||
track.stop();
|
||||
});
|
||||
localAudioStreamRef.current = null;
|
||||
}
|
||||
|
||||
updateState({
|
||||
isVoiceEnabled: false,
|
||||
localAudioStream: null,
|
||||
isMuted: false,
|
||||
});
|
||||
}, [connection, state.isVoiceEnabled, updateState]);
|
||||
|
||||
// 切换静音状态
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!localAudioStreamRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioTracks = localAudioStreamRef.current.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newMutedState = !state.isMuted;
|
||||
audioTracks.forEach(track => {
|
||||
track.enabled = !newMutedState;
|
||||
});
|
||||
|
||||
updateState({ isMuted: newMutedState });
|
||||
}, [state.isMuted, updateState]);
|
||||
|
||||
// 设置远程音频元素引用
|
||||
const setRemoteAudioRef = useCallback((element: HTMLAudioElement | null) => {
|
||||
remoteAudioRef.current = element;
|
||||
if (element && state.remoteAudioStream && state.remoteAudioStream.active) {
|
||||
element.srcObject = state.remoteAudioStream;
|
||||
element.play().catch(err => {
|
||||
// 忽略 AbortError,这是正常的竞态条件
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('[VoiceChat] 播放远程音频失败:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [state.remoteAudioStream]);
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (localAudioStreamRef.current) {
|
||||
localAudioStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isVoiceEnabled: state.isVoiceEnabled,
|
||||
isMuted: state.isMuted,
|
||||
isRemoteVoiceActive: state.isRemoteVoiceActive,
|
||||
error: state.error,
|
||||
|
||||
// 音频可视化数据
|
||||
localVolume: localAudioVisualizer.volume,
|
||||
localIsSpeaking: localAudioVisualizer.isSpeaking,
|
||||
remoteVolume: remoteAudioVisualizer.volume,
|
||||
remoteIsSpeaking: remoteAudioVisualizer.isSpeaking,
|
||||
|
||||
// 方法
|
||||
enableVoice,
|
||||
disableVoice,
|
||||
toggleMute,
|
||||
setRemoteAudioRef,
|
||||
|
||||
// 调试信息
|
||||
_debug: {
|
||||
hasRemoteStream: !!state.remoteAudioStream,
|
||||
remoteStreamId: state.remoteAudioStream?.id,
|
||||
remoteTrackCount: state.remoteAudioStream?.getTracks().length || 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}));
|
||||
@@ -135,9 +148,9 @@ export const useFileStateManager = ({
|
||||
});
|
||||
|
||||
// 检查文件列表是否真正发生变化
|
||||
const fileListChanged =
|
||||
const fileListChanged =
|
||||
newFileInfos.length !== currentFileList.length ||
|
||||
newFileInfos.some(newFile =>
|
||||
newFileInfos.some(newFile =>
|
||||
!currentFileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
|
||||
);
|
||||
|
||||
@@ -146,7 +159,7 @@ export const useFileStateManager = ({
|
||||
before: currentFileList.map(f => f.name),
|
||||
after: newFileInfos.map(f => f.name)
|
||||
});
|
||||
|
||||
|
||||
return newFileInfos;
|
||||
}
|
||||
|
||||
@@ -155,6 +168,20 @@ export const useFileStateManager = ({
|
||||
});
|
||||
}, [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,
|
||||
setSelectedFiles,
|
||||
@@ -166,6 +193,7 @@ export const useFileStateManager = ({
|
||||
clearFiles,
|
||||
resetFiles,
|
||||
updateFileStatus,
|
||||
updateFileProgress
|
||||
updateFileProgress,
|
||||
clearSenderData
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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') => {
|
||||
@@ -388,15 +386,15 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
// 主要检查数据通道状态,因为数据通道是文件传输的实际通道
|
||||
const channelState = connection.getChannelState();
|
||||
if (channelState === 'closed') {
|
||||
const channelState = connection.getConnectState();
|
||||
if (channelState.state === 'closed') {
|
||||
console.warn(`数据通道已关闭,停止发送文件块 ${chunkIndex}`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
|
||||
if (!connection.isConnected && channelState === 'connecting') {
|
||||
if (!channelState.isConnected && channelState.state === 'connecting') {
|
||||
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
|
||||
}
|
||||
|
||||
@@ -445,15 +443,16 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
|
||||
// 安全发送文件
|
||||
const sendFileSecure = useCallback(async (file: File, fileId?: string) => {
|
||||
if (connection.getChannelState() !== 'open') {
|
||||
if (connection.getConnectState().state !== 'open') {
|
||||
updateState({ error: '连接未就绪' });
|
||||
return;
|
||||
}
|
||||
|
||||
const actualFileId = fileId || `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
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 });
|
||||
|
||||
@@ -490,19 +489,19 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
|
||||
while (!success && retryCount <= MAX_RETRIES) {
|
||||
// 检查数据通道状态,这是文件传输的实际通道
|
||||
const channelState = connection.getChannelState();
|
||||
if (channelState === 'closed') {
|
||||
const channelState = connection.getConnectState();
|
||||
if (channelState.state === 'closed') {
|
||||
console.warn(`数据通道已关闭,停止文件传输`);
|
||||
throw new Error('数据通道已关闭');
|
||||
}
|
||||
|
||||
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
|
||||
if (!connection.isConnected && channelState === 'connecting') {
|
||||
if (!connection.getConnectState().isConnected && channelState.state === '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 +552,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);
|
||||
|
||||
@@ -598,41 +597,88 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
// 发送文件列表
|
||||
const sendFileList = useCallback((fileList: FileInfo[]) => {
|
||||
// 检查连接状态 - 优先检查数据通道状态,因为 P2P 连接可能已经建立但状态未及时更新
|
||||
const channelState = connection.getChannelState();
|
||||
const peerConnected = connection.isPeerConnected;
|
||||
const channelState = connection.getConnectState();
|
||||
const peerConnected = channelState.isPeerConnected;
|
||||
const dataChannelConnected = channelState.isDataChannelConnected;
|
||||
const channelReadyState = channelState.state;
|
||||
|
||||
console.log('发送文件列表检查:', {
|
||||
channelState,
|
||||
peerConnected,
|
||||
dataChannelConnected,
|
||||
channelReadyState,
|
||||
fileListLength: fileList.length
|
||||
});
|
||||
|
||||
// 如果数据通道已打开或者 P2P 已连接,就可以发送文件列表
|
||||
if (channelState === 'open' || peerConnected) {
|
||||
console.log('发送文件列表:', fileList);
|
||||
// 使用更宽松的条件检查连接状态
|
||||
const isReadyToSend = channelReadyState === 'open' ||
|
||||
dataChannelConnected ||
|
||||
peerConnected ||
|
||||
channelState.isConnected;
|
||||
|
||||
connection.sendMessage({
|
||||
if (isReadyToSend) {
|
||||
console.log('发送文件列表:', fileList.map(f => f.name));
|
||||
|
||||
const sendResult = connection.sendMessage({
|
||||
type: 'file-list',
|
||||
payload: fileList
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
if (!sendResult) {
|
||||
console.warn('文件列表发送失败,可能是数据通道未准备好');
|
||||
// 不立即重试,让上层逻辑处理重试
|
||||
}
|
||||
} else {
|
||||
console.log('P2P连接未建立,等待连接后再发送文件列表');
|
||||
console.log('连接未就绪,等待连接后再发送文件列表:', {
|
||||
channelReadyState,
|
||||
dataChannelConnected,
|
||||
peerConnected,
|
||||
isConnected: channelState.isConnected
|
||||
});
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
// 请求文件
|
||||
const requestFile = useCallback((fileId: string, fileName: string) => {
|
||||
if (connection.getChannelState() !== 'open') {
|
||||
console.error('数据通道未准备就绪,无法请求文件');
|
||||
const channelState = connection.getConnectState();
|
||||
const isChannelOpen = channelState.state === 'open';
|
||||
const isDataChannelConnected = channelState.isDataChannelConnected;
|
||||
const isPeerConnected = channelState.isPeerConnected;
|
||||
const isConnected = channelState.isConnected;
|
||||
|
||||
console.log('请求文件前检查连接状态:', {
|
||||
fileName,
|
||||
fileId,
|
||||
isChannelOpen,
|
||||
isDataChannelConnected,
|
||||
isPeerConnected,
|
||||
isConnected
|
||||
});
|
||||
|
||||
// 使用更宽松的条件检查连接状态
|
||||
const isReadyToRequest = isChannelOpen || isDataChannelConnected || isPeerConnected || isConnected;
|
||||
|
||||
if (!isReadyToRequest) {
|
||||
console.error('数据通道未准备就绪,无法请求文件:', {
|
||||
isChannelOpen,
|
||||
isDataChannelConnected,
|
||||
isPeerConnected,
|
||||
isConnected
|
||||
});
|
||||
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 +702,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 +744,7 @@ export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
sendFile,
|
||||
sendFileList,
|
||||
requestFile,
|
||||
clearSenderData,
|
||||
|
||||
// 回调注册
|
||||
onFileReceived,
|
||||
|
||||
@@ -7,36 +7,48 @@ export interface IceServerConfig {
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: 'google-stun-3',
|
||||
urls: 'stun:stun2.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';
|
||||
const STORAGE_KEY = 'webrtc-ice-servers-config-090901';
|
||||
|
||||
export function useIceServersConfig() {
|
||||
const [iceServers, setIceServers] = useState<IceServerConfig[]>([]);
|
||||
@@ -48,7 +60,13 @@ export function useIceServersConfig() {
|
||||
const savedConfig = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedConfig) {
|
||||
const parsed = JSON.parse(savedConfig);
|
||||
setIceServers(parsed);
|
||||
// 确保所有服务器都有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);
|
||||
}
|
||||
@@ -76,6 +94,7 @@ export function useIceServersConfig() {
|
||||
const newServer: IceServerConfig = {
|
||||
...config,
|
||||
id: `custom-${Date.now()}`,
|
||||
isDefault: false, // 用户添加的服务器不标记为默认
|
||||
};
|
||||
const updatedServers = [...iceServers, newServer];
|
||||
saveConfig(updatedServers);
|
||||
@@ -95,6 +114,7 @@ export function useIceServersConfig() {
|
||||
if (iceServers.length <= 1) {
|
||||
throw new Error('至少需要保留一个ICE服务器');
|
||||
}
|
||||
|
||||
const updatedServers = iceServers.filter(server => server.id !== id);
|
||||
saveConfig(updatedServers);
|
||||
}, [iceServers, saveConfig]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { useSharedWebRTCManager } from '../connection/useSharedWebRTCManager';
|
||||
import { FeatureType, useURLHandler } from './useURLHandler';
|
||||
|
||||
// Tab类型定义(包括非WebRTC功能)
|
||||
export type TabType = 'webrtc' | 'message' | 'desktop' | 'wechat' | 'settings';
|
||||
@@ -29,33 +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,
|
||||
} = useWebRTCStore();
|
||||
|
||||
// 获取WebRTC连接管理器
|
||||
const { disconnect: disconnectWebRTC } = useSharedWebRTCManager();
|
||||
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标签页');
|
||||
@@ -70,7 +70,7 @@ export const useTabNavigation = () => {
|
||||
console.log('没有有效的type参数,使用默认标签页:webrtc(文件传输)');
|
||||
// 保持默认的webrtc标签
|
||||
}
|
||||
|
||||
|
||||
setHasInitialized(true);
|
||||
}
|
||||
}, [searchParams, hasInitialized]);
|
||||
@@ -79,7 +79,7 @@ export const useTabNavigation = () => {
|
||||
const handleTabChange = useCallback(async (newTab: TabType) => {
|
||||
console.log('=== Tab切换 ===');
|
||||
console.log('当前tab:', activeTab, '目标tab:', newTab);
|
||||
|
||||
|
||||
// 对于任何非WebRTC功能的tab(wechat、settings),如果有活跃连接需要确认
|
||||
if ((newTab === 'wechat' || newTab === 'settings') && hasActiveConnection()) {
|
||||
const currentTabName = TAB_NAMES[activeTab];
|
||||
@@ -91,15 +91,15 @@ export const useTabNavigation = () => {
|
||||
cancelText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// 断开连接并清除状态
|
||||
disconnectWebRTC();
|
||||
console.log(`已清除WebRTC连接状态,切换到${targetTabName}`);
|
||||
|
||||
|
||||
setActiveTab(newTab);
|
||||
// 清除URL参数
|
||||
const newUrl = new URL(window.location.href);
|
||||
@@ -122,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}连接,是否继续?`,
|
||||
@@ -130,7 +130,7 @@ export const useTabNavigation = () => {
|
||||
cancelText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
@@ -142,7 +142,7 @@ export const useTabNavigation = () => {
|
||||
|
||||
// 执行tab切换
|
||||
setActiveTab(newTab);
|
||||
|
||||
|
||||
// 更新URL(对于WebRTC功能)
|
||||
if (WEBRTC_FEATURES[newTab]) {
|
||||
const params = new URLSearchParams();
|
||||
@@ -156,7 +156,7 @@ export const useTabNavigation = () => {
|
||||
newUrl.search = '';
|
||||
window.history.pushState({}, '', newUrl.toString());
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}, [activeTab, hasActiveConnection, disconnectWebRTC]);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
// 支持的功能类型
|
||||
|
||||
@@ -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), // 完全重置到初始状态
|
||||
}));
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
|
||||
@@ -26,16 +26,17 @@ 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';
|
||||
}
|
||||
|
||||
|
||||
// 生产模式或通过 Go 服务器访问:使用当前域名和端口
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}`;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
273
chuan-next/src/lib/transfer-utils.ts
Normal file
273
chuan-next/src/lib/transfer-utils.ts
Normal 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);
|
||||
}
|
||||
178
cmd/config.go
Normal file
178
cmd/config.go
Normal 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服务器已禁用")
|
||||
}
|
||||
}
|
||||
103
cmd/main.go
103
cmd/main.go
@@ -1,104 +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("/api/ws/webrtc", h.HandleWebRTCWebSocket)
|
||||
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
76
cmd/router.go
Normal 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
111
cmd/server.go
Normal 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()
|
||||
}
|
||||
12
go.mod
12
go.mod
@@ -6,4 +6,16 @@ require (
|
||||
github.com/go-chi/chi/v5 v5.0.10
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/pion/turn/v3 v3.0.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
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
)
|
||||
|
||||
91
go.sum
91
go.sum
@@ -1,8 +1,95 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
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 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
234
internal/services/turn_service.go
Normal file
234
internal/services/turn_service.go
Normal 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"`
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
@@ -146,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user