mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-13 00:24:44 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7485059cf | ||
|
|
04d4af5ef1 | ||
|
|
08f9d50e66 | ||
|
|
2fc478e889 | ||
|
|
15d23de5a7 | ||
|
|
550be8bcc6 | ||
|
|
50d30f23bf | ||
|
|
4b31e76488 | ||
|
|
84d7caea8c | ||
|
|
343e7f1192 | ||
|
|
07409abb3b | ||
|
|
1e5d74433b | ||
|
|
8e4c42bbbe | ||
|
|
6d5b4329db | ||
|
|
dfa225e68e | ||
|
|
4faf1c3141 | ||
|
|
86fd9ec08c | ||
|
|
0c33a72c0a | ||
|
|
bbf303711d | ||
|
|
17a44c866d | ||
|
|
57c5fa7687 | ||
|
|
109b918953 | ||
|
|
055deea67a | ||
|
|
0fd8899fc6 | ||
|
|
4bf0ce447d | ||
|
|
bc01224c11 | ||
|
|
63e6e956e4 | ||
|
|
301434fd4c | ||
|
|
fbb5135eed | ||
|
|
0caeaf62c4 | ||
|
|
6b69d35a20 | ||
|
|
75825e1104 | ||
|
|
720f808ed6 | ||
|
|
2abf7bdf42 |
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. 值可以用单引号或双引号包围
|
||||
91
.dockerignore
Normal file
91
.dockerignore
Normal file
@@ -0,0 +1,91 @@
|
||||
# ==============================================
|
||||
# Docker 忽略文件
|
||||
# 优化构建上下文,减少构建时间
|
||||
# ==============================================
|
||||
|
||||
# Git 相关
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# 文档
|
||||
README.md
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# 开发配置
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
.envrc
|
||||
|
||||
# IDE 和编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.npm
|
||||
.yarn-integrity
|
||||
|
||||
# Next.js
|
||||
chuan-next/.next/
|
||||
chuan-next/out/
|
||||
chuan-next/.env*
|
||||
chuan-next/build.log
|
||||
|
||||
# Go 相关
|
||||
# *.sum # 注释掉这行,因为需要 go.sum 文件
|
||||
vendor/
|
||||
|
||||
# 构建输出
|
||||
dist/
|
||||
build/
|
||||
*.exe
|
||||
*.exe~
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
temp/
|
||||
.tmp
|
||||
|
||||
# OS 生成文件
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Docker 相关
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
.travis.yml
|
||||
.circleci/
|
||||
|
||||
# 测试
|
||||
coverage/
|
||||
.nyc_output/
|
||||
.coverage
|
||||
|
||||
# 其他构建工具
|
||||
.sass-cache/
|
||||
.cache/
|
||||
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: 🐛 Bug 报告
|
||||
description: 报告一个错误或问题
|
||||
title: "[Bug] "
|
||||
labels: ["bug", "需要调查"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间填写错误报告!请详细描述遇到的问题,这将帮助我们更快地定位和解决问题。
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 请详细描述您遇到的问题
|
||||
placeholder: |
|
||||
例如:在文件传输过程中,当传输大文件时连接会意外断开...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment-environment
|
||||
attributes:
|
||||
label: 部署环境
|
||||
description: 您使用的是什么部署方式?
|
||||
options:
|
||||
- 二进制部署(下载发布的可执行文件)
|
||||
- 自行构建(从源码编译)
|
||||
- Docker 部署
|
||||
- 官方演示站
|
||||
- 其他(请在详细信息中说明)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment-details
|
||||
attributes:
|
||||
label: 环境详细信息
|
||||
description: 请提供环境相关信息
|
||||
placeholder: |
|
||||
- 操作系统:Linux Ubuntu 20.04 / Windows 10 / macOS 12.x
|
||||
- 浏览器:Chrome 120.x / Firefox 121.x / Safari 17.x
|
||||
- 网络环境:局域网 / 公网 / NAT环境
|
||||
- 设备类型:PC / 移动设备
|
||||
- 其他相关信息...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 请描述如何复现这个问题
|
||||
placeholder: |
|
||||
1. 打开应用
|
||||
2. 点击 '文件传输'
|
||||
3. 选择一个大文件 (>100MB)
|
||||
4. 点击发送
|
||||
5. 观察到连接断开...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 相关日志
|
||||
description: |
|
||||
请提供相关的错误日志、控制台输出或服务器日志
|
||||
提示:您可以在浏览器开发者工具的控制台中查看客户端日志
|
||||
render: text
|
||||
placeholder: |
|
||||
[2024-01-15 10:30:45] [ERROR] WebRTC连接失败: ICE连接超时
|
||||
[2024-01-15 10:30:45] [INFO] 尝试重新连接...
|
||||
|
||||
或者粘贴浏览器控制台的错误信息...
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图或录屏
|
||||
description: |
|
||||
如果适用,请添加截图或录屏来帮助解释您的问题
|
||||
您可以直接拖拽图片到这个文本框中
|
||||
placeholder: 拖拽图片文件到这里,或者粘贴图片链接
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 其他信息
|
||||
description: 任何其他可能有助于解决问题的信息
|
||||
placeholder: |
|
||||
- 问题发生的频率:每次 / 偶尔 / 特定条件下
|
||||
- 是否在多个设备上都出现
|
||||
- 最近是否有环境变化
|
||||
- 其他可能相关的信息...
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 📚 项目文档
|
||||
url: https://github.com/MatrixSeven/file-transfer-go/blob/main/README.md
|
||||
about: 查看项目使用文档和部署指南
|
||||
- name: 💬 讨论区
|
||||
url: https://github.com/MatrixSeven/file-transfer-go/discussions
|
||||
about: 参与社区讨论,分享使用经验和想法
|
||||
- name: 🌐 官方演示
|
||||
url: https://transfer.52python.cn/
|
||||
about: 访问官方演示站点体验功能(如果可用)
|
||||
72
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
72
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: ✨ 功能请求
|
||||
description: 建议一个新功能或改进
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement", "功能请求"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您提出功能建议!您的想法对改进项目非常重要。请详细描述您的建议。
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: 建议功能
|
||||
description: 请清楚简洁地描述您希望实现的功能
|
||||
placeholder: |
|
||||
例如:希望添加文件加密传输功能,在传输过程中对文件进行端到端加密...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: 需求原因
|
||||
description: 请解释为什么需要这个功能,它解决了什么问题?
|
||||
placeholder: |
|
||||
例如:
|
||||
- 当前在传输敏感文件时缺乏安全保障
|
||||
- 在公网环境下传输文件可能被第三方截获
|
||||
- 企业用户需要确保数据传输的安全性
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-cases
|
||||
attributes:
|
||||
label: 使用场景
|
||||
description: 请描述这个功能的具体使用场景和出发点
|
||||
placeholder: |
|
||||
例如:
|
||||
- 医疗机构传输患者档案时需要加密保护
|
||||
- 企业内部传输财务报表等敏感文档
|
||||
- 个人用户传输私人照片和视频时希望保护隐私
|
||||
- 在不受信任的网络环境下进行文件传输
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级
|
||||
description: 您认为这个功能的优先级如何?
|
||||
options:
|
||||
- 低(Nice to have)
|
||||
- 中(重要但不紧急)
|
||||
- 高(对用户体验很重要)
|
||||
- 关键(阻碍正常使用)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 其他信息
|
||||
description: 任何其他可能有助于理解和实现这个功能的信息
|
||||
placeholder: |
|
||||
- 类似功能的参考应用或网站
|
||||
- 相关技术文档或标准
|
||||
- 社区讨论链接
|
||||
- 其他补充说明
|
||||
|
||||
54
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
54
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: 💬 问题咨询
|
||||
description: 使用问题、配置疑问或一般性讨论
|
||||
title: "[Question] "
|
||||
labels: ["question", "需要回复"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
如果您有使用问题、配置疑问或想要讨论项目相关话题,请使用这个模板。
|
||||
|
||||
- type: dropdown
|
||||
id: question-type
|
||||
attributes:
|
||||
label: 问题类型
|
||||
description: 请选择您的问题类型
|
||||
options:
|
||||
- 使用问题(如何使用某个功能)
|
||||
- 配置问题(部署和设置相关)
|
||||
- 技术咨询(技术实现相关)
|
||||
- 功能理解(不确定某个功能如何工作)
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: 具体问题
|
||||
description: 请详细描述您的问题
|
||||
placeholder: |
|
||||
例如:我想在内网环境下部署这个应用,但是不知道如何配置STUN服务器...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息
|
||||
description: 如果相关,请提供环境信息
|
||||
placeholder: |
|
||||
- 操作系统:
|
||||
- 部署方式:
|
||||
- 网络环境:
|
||||
- 浏览器版本:
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: 确认事项
|
||||
options:
|
||||
- label: 我已经查看了项目文档和README
|
||||
required: true
|
||||
- label: 我已经搜索了现有的Issues
|
||||
required: true
|
||||
139
.github/workflows/docker-publish.yml
vendored
Normal file
139
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
|
||||
name: 🐳 Build and Push Docker Image (AMD64)
|
||||
|
||||
on:
|
||||
# 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号 (例如: v1.0.5)'
|
||||
required: true
|
||||
default: 'v1.0.5'
|
||||
type: string
|
||||
push_to_hub:
|
||||
description: '推送到 Docker Hub'
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
# 推送标签时触发
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
# PR 时构建测试(不推送)
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
REGISTRY: docker.io
|
||||
IMAGE_NAME: matrixseven/file-transfer-go
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 🏗️ Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: 🏷️ Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
- name: 🔧 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: |
|
||||
network=host
|
||||
buildkitd-flags: |
|
||||
--allow-insecure-entitlement=network.host
|
||||
|
||||
- name: 🔑 Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: 🏗️ Set build platform
|
||||
id: platforms
|
||||
run: |
|
||||
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 🐳 Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ steps.platforms.outputs.platforms }}
|
||||
push: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.push_to_hub == true) }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: 📊 Image digest
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: 🎉 Build Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📦 Image Details" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Registry**: ${{ env.REGISTRY }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Image**: ${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Tags**: ${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Platforms**: ${{ steps.platforms.outputs.platforms }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🚀 Usage" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```bash' >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker run -d -p 8080:8080 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.version || 'latest' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
security-scan:
|
||||
name: 🔍 Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔍 Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest'
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
|
||||
- name: 📤 Upload Trivy scan results
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
|
||||
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
|
||||
69
Dockerfile
Normal file
69
Dockerfile
Normal file
@@ -0,0 +1,69 @@
|
||||
# ==============================================
|
||||
# AMD64 Dockerfile - 基于 build-fullstack.sh 流程
|
||||
# ==============================================
|
||||
|
||||
# 前端构建阶段
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
# 安装 yarn
|
||||
RUN apk add --no-cache yarn
|
||||
|
||||
WORKDIR /app/chuan-next
|
||||
|
||||
# 复制所有源代码(确保获取最新代码)
|
||||
COPY chuan-next/ ./
|
||||
|
||||
# 清理构建文件(模拟 build-fullstack.sh 的 clean_all 函数)
|
||||
RUN rm -rf .next out
|
||||
|
||||
# 前端依赖和构建
|
||||
COPY chuan-next/package.json chuan-next/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile --network-timeout 300000
|
||||
|
||||
# 临时移除 API 目录进行 SSG 构建(模拟 build-fullstack.sh 的 build_frontend 函数)
|
||||
RUN if [ -d "src/app/api" ]; then mv src/app/api /tmp/api-backup; fi && \
|
||||
NEXT_EXPORT=true NODE_ENV=production NEXT_PUBLIC_BACKEND_URL= NEXT_PUBLIC_WS_URL= NEXT_PUBLIC_API_BASE_URL= yarn build && \
|
||||
if [ -d "/tmp/api-backup" ]; then mv /tmp/api-backup src/app/api; fi
|
||||
|
||||
# ==============================================
|
||||
|
||||
# Go 构建阶段
|
||||
FROM golang:1.21-alpine AS go-builder
|
||||
|
||||
# 安装构建依赖
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
ENV GOPROXY=https://proxy.golang.org,direct
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOOS=linux
|
||||
ENV GOARCH=amd64
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先复制所有源代码(确保获取最新代码)
|
||||
COPY . .
|
||||
|
||||
# Go 依赖
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# 拷贝前端构建结果
|
||||
COPY --from=frontend-builder /app/chuan-next/out ./internal/web/frontend/
|
||||
|
||||
# 构建 Go 应用 - AMD64 架构(模拟 build-fullstack.sh 的 build_backend 函数)
|
||||
RUN go build -ldflags='-s -w -extldflags '-static'' -o server ./cmd
|
||||
|
||||
# ==============================================
|
||||
|
||||
# 最终镜像
|
||||
FROM alpine:3.18
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
adduser -D -s /bin/sh appuser
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=go-builder --chown=appuser:appuser /app/server ./
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["./server"]
|
||||
155
README.md
155
README.md
@@ -1,29 +1,110 @@
|
||||
# 文件快传 - P2P文件传输工具
|
||||
|
||||
|
||||
### 在线体验 https://transfer.52python.cn
|
||||
**安全、快速、简单的点对点文件传输解决方案 - 无需注册,即传即用**
|
||||
|
||||
## [在线体验](https://transfer.52python.cn) • [关注我](https://x.com/_MatrixSeven) • [帮助文档](https://transfer.52python.cn/help)
|
||||
|
||||

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

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
- 📁 **文件传输** - 支持多文件同时传输
|
||||
- 📝 **文字传输** - 快速分享文本内容
|
||||
- 🖥️ **桌面共享** - 实时屏幕共享(开发中)
|
||||
- 🖥️ **桌面共享** - 实时屏幕共享
|
||||
- 🔗 **连接状态同步** - 实时连接状态UI同步
|
||||
- 🔒 **端到端加密** - 数据传输安全,服务器不存储文件
|
||||
- 📱 **响应式设计** - 完美适配手机、平板、电脑
|
||||
- 🖥️ **多平台支持** - 支持linux/macos/win 单文件部署
|
||||
|
||||
## 🔄 最近更新日志
|
||||
### 2025-11-24
|
||||
- ✅ **共享桌面** - 共享桌面支持开启语音,提升实用性
|
||||
|
||||
### 2025-09-5
|
||||
- ✅ **WEBRTC链接恢复** - 关闭页面后在打开,进行数据链接恢复
|
||||
- ✅ **定义TURN配置** - 支持自定义中继TURN配置
|
||||
- ✅ **优化移动端提示** - 优化各种场景的错误提示
|
||||
- ✅ **帮助文档** - 常见问题说明文档更新
|
||||
|
||||
### 2025-09-1
|
||||
- ✅ **移动端桌面全屏** - 优化移动端下UI,并解决全屏问题
|
||||
|
||||
### 2025-08-28
|
||||
- ✅ **完善Docker部署支持** - 优化Docker配置,支持一键部署和多环境配置
|
||||
- ✅ **优化README文档** - 更新项目说明,完善部署指南和技术栈信息
|
||||
- ✅ **改进UI用户体验** - 优化界面细节,完善错误提示和加载状态
|
||||
- ✅ **重构Hooks架构** - 拆分复杂hooks,提高代码复用性和可维护性
|
||||
|
||||
### 2025-08-24
|
||||
- ✅ **文件传输 ACK 确认支持** - 实现了可靠的数据传输机制,每个数据块都需要接收方确认
|
||||
- ✅ **修复组件渲染后重复注册/解绑 bug** - 解决了 React 组件重复渲染导致的处理器反复注册问题
|
||||
- ✅ **修复进度显示 Infinity% 问题** - 解决了除零错误和进度闪烁问题
|
||||
|
||||
### 2025-08-14
|
||||
- ✅ **分离UI组件,统一UI状态** - 重构UI架构,提高代码复用性和可维护性
|
||||
- ✅ **共享底层链接** - 优化WebRTC连接管理,支持多个业务模块共享连接
|
||||
- ✅ **远程桌面支持** - 新增实时屏幕共享功能
|
||||
- ✅ **修复 WebRTC 连接状态异常** - 增强了连接状态错误处理和恢复能力
|
||||
|
||||
## 🚀 技术栈
|
||||
|
||||
**前端** - Next.js 15 + React 18 + TypeScript + Tailwind CSS
|
||||
**后端** - Go + WebSocket + 内存存储
|
||||
**传输** - WebRTC DataChannel + P2P直连
|
||||
### 前端技术栈
|
||||
- **Next.js 15** - React全栈框架,支持SSR/SSG
|
||||
- **React 18** - 现代化UI组件库
|
||||
- **TypeScript 5** - 类型安全的JavaScript超集
|
||||
- **Tailwind CSS 3.4** - 实用优先的CSS框架
|
||||
- **Radix UI** - 无障碍访问的组件库
|
||||
- **Zustand** - 轻量级状态管理
|
||||
- **Lucide React** - 现代化图标库
|
||||
|
||||
### 后端技术栈
|
||||
- **Go 1.22** - 高性能编程语言
|
||||
- **WebSocket** - 实时双向通信
|
||||
- **内存存储** - 轻量级数据存储
|
||||
- **标准库** - 原生HTTP服务器
|
||||
|
||||
### 传输协议
|
||||
- **WebRTC DataChannel** - 端到端数据传输
|
||||
- **P2P直连** - 点对点连接,无需中转
|
||||
- **ICE框架** - 网络连接协商
|
||||
- **STUN/TURN** - NAT穿透支持
|
||||
|
||||
|
||||
### 架构特点
|
||||
- **微服务架构** - 前后端分离
|
||||
- **实时通信** - WebSocket + WebRTC
|
||||
- **响应式设计** - 移动端适配
|
||||
- **容器化** - Docker部署支持
|
||||
|
||||
## 📦 快速部署
|
||||
|
||||
### 方式一:Docker 一键部署(推荐)
|
||||
|
||||
```bash
|
||||
# 使用 Docker Compose(最简单)
|
||||
git clone https://github.com/MatrixSeven/file-transfer-go.git
|
||||
cd file-transfer-go
|
||||
docker-compose up -d
|
||||
|
||||
# 或者直接使用 Docker 镜像
|
||||
docker run -d -p 8080:8080 --name file-transfer-go matrixseven/file-transfer-go:latest
|
||||
```
|
||||
|
||||
### 方式二:本地构建部署
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MatrixSeven/file-transfer-go.git
|
||||
cd file-transfer-go
|
||||
@@ -33,13 +114,54 @@ cd file-transfer-go
|
||||
|
||||
访问 http://localhost:8080 开始使用
|
||||
|
||||
### 方式三:开发环境部署
|
||||
|
||||
```bash
|
||||
# 后端服务
|
||||
make dev
|
||||
|
||||
# 前端服务(新终端)
|
||||
cd chuan-next && yarn && yarn dev
|
||||
```
|
||||
|
||||
### 部署配置说明
|
||||
|
||||
#### 环境变量配置
|
||||
- `NODE_ENV`: 运行环境(development/production)
|
||||
- `PORT`: 服务端口(默认8080)
|
||||
- `GO_BACKEND_URL`: 后端服务地址
|
||||
|
||||
#### Docker 配置选项
|
||||
```yaml
|
||||
# docker-compose.yml 可配置项
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
#### 多架构支持
|
||||
项目支持多架构Docker镜像:
|
||||
- `linux/amd64` - x86_64 架构
|
||||
- `linux/arm64` - ARM 64位架构
|
||||
|
||||
#### 镜像版本
|
||||
- `latest` - 最新稳定版本
|
||||
- `v1.0.x` - 特定版本号
|
||||
- `dev` - 开发版本
|
||||
|
||||
## 🎯 使用方法
|
||||
|
||||
**发送文件**
|
||||
### 发送文件
|
||||
1. 选择文件 → 生成取件码 → 分享6位码
|
||||
|
||||
**接收文件**
|
||||
1. 输入取件码 → 自动连接 → 下载文件
|
||||
### 文字传输
|
||||
1. 输入文字内容 → 生成取件码 → 分享给对方
|
||||
|
||||
### 桌面共享
|
||||
1. 点击共享桌面 → 生成取件码 → 对方输入码观看
|
||||
|
||||
## 📊 项目架构
|
||||
|
||||
@@ -59,10 +181,19 @@ make dev
|
||||
cd chuan-next && yarn && yarn dev
|
||||
```
|
||||
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
⭐ 觉得有用请给个星标!
|
||||
<div align="center">
|
||||
|
||||
⭐ 如果觉得这个项目对你有帮助,请给个星标!
|
||||
|
||||
[]
|
||||
|
||||
</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 "✅ 部署完成"
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =============================================================================
|
||||
# 简化版 SSG 构建脚本
|
||||
# 专注于 API 路由的处理和静态导出
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 配置
|
||||
PROJECT_ROOT="$(pwd)"
|
||||
API_DIR="$PROJECT_ROOT/src/app/api"
|
||||
TEMP_API_DIR="/tmp/nextjs-api-$(date +%s)"
|
||||
|
||||
echo -e "${GREEN}🚀 开始 SSG 静态导出构建...${NC}"
|
||||
|
||||
# 错误处理函数
|
||||
cleanup_on_error() {
|
||||
echo -e "${RED}❌ 构建失败,正在恢复文件...${NC}"
|
||||
if [ -d "$TEMP_API_DIR" ]; then
|
||||
if [ -d "$TEMP_API_DIR/api" ]; then
|
||||
mv "$TEMP_API_DIR/api" "$API_DIR" 2>/dev/null || true
|
||||
echo -e "${YELLOW}📂 已恢复 API 目录${NC}"
|
||||
fi
|
||||
rm -rf "$TEMP_API_DIR"
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 设置错误处理
|
||||
trap cleanup_on_error ERR INT TERM
|
||||
|
||||
# 步骤 1: 备份 API 路由
|
||||
if [ -d "$API_DIR" ]; then
|
||||
echo -e "${YELLOW}📦 备份 API 路由...${NC}"
|
||||
mkdir -p "$TEMP_API_DIR"
|
||||
mv "$API_DIR" "$TEMP_API_DIR/"
|
||||
echo "✅ API 路由已备份到临时目录"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ API 目录不存在,跳过备份${NC}"
|
||||
fi
|
||||
|
||||
# 步骤 2: 清理构建文件
|
||||
echo -e "${YELLOW}🧹 清理之前的构建...${NC}"
|
||||
rm -rf .next out
|
||||
|
||||
# 步骤 3: 执行静态构建
|
||||
echo -e "${YELLOW}🔨 执行静态导出构建...${NC}"
|
||||
NEXT_EXPORT=true yarn build
|
||||
|
||||
# 步骤 4: 验证构建结果
|
||||
if [ -d "out" ] && [ -f "out/index.html" ]; then
|
||||
echo -e "${GREEN}✅ 静态导出构建成功!${NC}"
|
||||
|
||||
# 显示构建统计
|
||||
file_count=$(find out -type f | wc -l)
|
||||
dir_size=$(du -sh out | cut -f1)
|
||||
echo "📊 构建统计:"
|
||||
echo " - 文件数量: $file_count"
|
||||
echo " - 总大小: $dir_size"
|
||||
else
|
||||
echo -e "${RED}❌ 构建验证失败${NC}"
|
||||
cleanup_on_error
|
||||
fi
|
||||
|
||||
# 步骤 5: 恢复 API 路由
|
||||
if [ -d "$TEMP_API_DIR/api" ]; then
|
||||
echo -e "${YELLOW}🔄 恢复 API 路由...${NC}"
|
||||
mv "$TEMP_API_DIR/api" "$API_DIR"
|
||||
echo "✅ API 路由已恢复"
|
||||
fi
|
||||
|
||||
# 步骤 6: 清理临时文件
|
||||
rm -rf "$TEMP_API_DIR"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 SSG 构建完成!${NC}"
|
||||
echo -e "${GREEN}📁 静态文件位于: ./out/${NC}"
|
||||
echo -e "${GREEN}🚀 部署命令: npx serve out${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}💡 提示: 静态版本会直接连接到 Go 后端 (localhost:8080)${NC}"
|
||||
0
chuan-next/build-static.sh
Normal file → Executable file
0
chuan-next/build-static.sh
Normal file → Executable file
@@ -35,7 +35,8 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
@@ -1,105 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Upload, MessageSquare, Monitor, TestTube } 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 { useWebRTCConfigSync } from '@/hooks/settings';
|
||||
import { TabType, useTabNavigation } from '@/hooks/ui';
|
||||
import { MessageSquare, Monitor, Settings, Upload, Users } from 'lucide-react';
|
||||
|
||||
export default function HomePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState('webrtc');
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
// 根据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标签页');
|
||||
setActiveTab('message');
|
||||
} else if (urlType === 'webrtc') {
|
||||
// webrtc类型对应文件传输标签页
|
||||
console.log('检测到webrtc类型,切换到webrtc标签页(文件传输)');
|
||||
setActiveTab('webrtc');
|
||||
} else if (urlType && ['message', 'desktop'].includes(urlType)) {
|
||||
console.log('切换到对应标签页:', urlType);
|
||||
setActiveTab(urlType);
|
||||
} else {
|
||||
console.log('没有有效的type参数,使用默认标签页:webrtc(文件传输)');
|
||||
}
|
||||
|
||||
setHasInitialized(true);
|
||||
}
|
||||
}, [searchParams, hasInitialized]);
|
||||
// WebRTC配置同步
|
||||
useWebRTCConfigSync();
|
||||
|
||||
// 使用tab导航hook
|
||||
const {
|
||||
activeTab,
|
||||
handleTabChange,
|
||||
confirmDialogState,
|
||||
closeConfirmDialog
|
||||
} = useTabNavigation();
|
||||
|
||||
// WebRTC 支持检测
|
||||
const {
|
||||
webrtcSupport,
|
||||
isSupported,
|
||||
isChecked,
|
||||
showUnsupportedModal,
|
||||
closeUnsupportedModal,
|
||||
showUnsupportedModalManually,
|
||||
} = useWebRTCSupport();
|
||||
|
||||
|
||||
// 处理Tabs组件的字符串参数
|
||||
const handleTabChangeWrapper = (value: string) => {
|
||||
// 类型转换并调用实际的处理函数
|
||||
handleTabChange(value as TabType);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
{/* Tabs Navigation - 横向布局 */}
|
||||
<div className="mb-6">
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-xl mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">
|
||||
<TabsTrigger
|
||||
value="webrtc"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-blue-600"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">文件传输</span>
|
||||
<span className="sm:hidden">文件</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="message"
|
||||
className="flex items-center justify-center space-x-2 px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-slate-50 data-[state=active]:bg-emerald-500 data-[state=active]:text-white data-[state=active]:shadow-md data-[state=active]:hover:bg-emerald-600"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">文本消息</span>
|
||||
<span className="sm:hidden">消息</span>
|
||||
</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"
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">共享桌面</span>
|
||||
<span className="sm:hidden">桌面</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
<div>
|
||||
<TabsContent value="webrtc" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCFileTransfer />
|
||||
</TabsContent>
|
||||
{/* 主要内容 - 只有在检测完成后才显示 */}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TabsContent value="message" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCTextImageTransfer />
|
||||
</TabsContent>
|
||||
<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>
|
||||
|
||||
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
|
||||
<DesktopShare />
|
||||
</TabsContent>
|
||||
{/* WebRTC 不支持时的提示 */}
|
||||
{!isSupported && (
|
||||
<p className="text-center text-xs text-gray-500 mt-2">
|
||||
* 需要 WebRTC 支持才能使用
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div>
|
||||
<TabsContent value="webrtc" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCFileTransfer />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="message" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCTextImageTransfer />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="desktop" className="mt-0 animate-fade-in-up">
|
||||
<DesktopShare />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="wechat" className="mt-0 animate-fade-in-up">
|
||||
<WeChatGroup />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="mt-0 animate-fade-in-up">
|
||||
<WebRTCSettings />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页脚 */}
|
||||
<Footer />
|
||||
|
||||
{/* WebRTC 不支持提示模态框 */}
|
||||
{webrtcSupport && (
|
||||
<WebRTCUnsupportedModal
|
||||
isOpen={showUnsupportedModal}
|
||||
onClose={closeUnsupportedModal}
|
||||
webrtcSupport={webrtcSupport}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 自定义确认对话框 */}
|
||||
{confirmDialogState && (
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDialogState.isOpen}
|
||||
onClose={closeConfirmDialog}
|
||||
onConfirm={confirmDialogState.onConfirm}
|
||||
title={confirmDialogState.title}
|
||||
message={confirmDialogState.message}
|
||||
confirmText={confirmDialogState.confirmText}
|
||||
cancelText={confirmDialogState.cancelText}
|
||||
type={confirmDialogState.type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
console.log('API Route: Creating room, proxying to:', `${GO_BACKEND_URL}/api/create-room`);
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// 不再需要解析和转发请求体,因为后端会忽略它们
|
||||
const response = await fetch(`${GO_BACKEND_URL}/api/create-room`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// 发送空body即可
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -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;
|
||||
|
||||
761
chuan-next/src/app/help/HelpPage.tsx
Normal file
761
chuan-next/src/app/help/HelpPage.tsx
Normal file
@@ -0,0 +1,761 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Book,
|
||||
Server,
|
||||
Download,
|
||||
Code,
|
||||
Container,
|
||||
Globe,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Lightbulb,
|
||||
HelpCircle,
|
||||
Upload,
|
||||
MessageSquare,
|
||||
Monitor,
|
||||
Settings,
|
||||
Shield,
|
||||
Smartphone,
|
||||
Wifi,
|
||||
Users,
|
||||
Home,
|
||||
ArrowLeft
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface SectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ id, title, icon, children }: SectionProps) {
|
||||
return (
|
||||
<section id={id} className="mb-6 scroll-mt-16 lg:scroll-mt-20">
|
||||
<div className="flex items-center gap-3 mb-4 lg:mb-6">
|
||||
<div className="p-2 lg:p-3 bg-blue-100 rounded-lg">
|
||||
{icon}
|
||||
</div>
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-gray-900">{title}</h2>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 lg:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
function CodeBlock({ code, language = "bash" }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="bg-gray-900 rounded-lg p-4 overflow-x-auto">
|
||||
<pre className="text-green-400 text-sm font-mono">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 text-gray-400 hover:text-white"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InfoBoxProps {
|
||||
type: 'info' | 'warning' | 'tip' | 'error';
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function InfoBox({ type, title, children }: InfoBoxProps) {
|
||||
const styles = {
|
||||
info: {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
icon: <Info className="w-5 h-5 text-blue-600" />,
|
||||
titleColor: 'text-blue-900'
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-50',
|
||||
border: 'border-yellow-200',
|
||||
icon: <AlertTriangle className="w-5 h-5 text-yellow-600" />,
|
||||
titleColor: 'text-yellow-900'
|
||||
},
|
||||
tip: {
|
||||
bg: 'bg-green-50',
|
||||
border: 'border-green-200',
|
||||
icon: <Lightbulb className="w-5 h-5 text-green-600" />,
|
||||
titleColor: 'text-green-900'
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
icon: <AlertTriangle className="w-5 h-5 text-red-600" />,
|
||||
titleColor: 'text-red-900'
|
||||
}
|
||||
};
|
||||
|
||||
const style = styles[type];
|
||||
|
||||
return (
|
||||
<div className={`${style.bg} ${style.border} border rounded-lg p-4 my-4`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{style.icon}
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-semibold ${style.titleColor} mb-2`}>{title}</h4>
|
||||
<div className="text-sm text-gray-700">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HelpPage() {
|
||||
const [activeSection, setActiveSection] = useState('deployment');
|
||||
const [sidebarLeft, setSidebarLeft] = useState(0);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'deployment',
|
||||
title: '部署指南',
|
||||
icon: <Server className="w-5 h-5 text-blue-600" />,
|
||||
children: [
|
||||
{ id: 'docker-deployment', title: 'Docker 部署', icon: <Container className="w-4 h-4 text-blue-500" /> },
|
||||
{ id: 'binary-deployment', title: '二进制部署', icon: <Download className="w-4 h-4 text-green-500" /> },
|
||||
{ id: 'build-deployment', title: '自行构建', icon: <Code className="w-4 h-4 text-purple-500" /> },
|
||||
]
|
||||
},
|
||||
{ id: 'desktop-share', title: '桌面共享权限问题', icon: <Monitor className="w-5 h-5 text-blue-600" /> },
|
||||
{ id: 'port-config', title: '自定义端口配置', icon: <Settings className="w-5 h-5 text-blue-600" /> },
|
||||
{ id: 'security', title: '全局域网部署', icon: <Shield className="w-5 h-5 text-blue-600" /> },
|
||||
{ id: 'data-transfer', title: '数据传输机制', icon: <Wifi className="w-5 h-5 text-blue-600" /> },
|
||||
{ id: 'contact', title: '交流反馈群', icon: <Users className="w-5 h-5 text-blue-600" /> },
|
||||
];
|
||||
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
setActiveSection(sectionId);
|
||||
// 更新 URL hash
|
||||
window.history.pushState(null, '', `#${sectionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化时检查 URL hash 并滚动到对应位置
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
if (hash) {
|
||||
// 延迟一下确保 DOM 已经渲染完成
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
setActiveSection(hash);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 监听滚动事件来更新活跃的章节和 URL hash
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollPosition = window.scrollY + 100;
|
||||
|
||||
// 检查所有可能的section ID(包括子目录)
|
||||
const allSectionIds = sections.reduce<string[]>((acc, section) => {
|
||||
acc.push(section.id);
|
||||
if (section.children) {
|
||||
acc.push(...section.children.map(child => child.id));
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
for (const sectionId of allSectionIds) {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
const { offsetTop, offsetHeight } = element;
|
||||
if (scrollPosition >= offsetTop && scrollPosition < offsetTop + offsetHeight) {
|
||||
setActiveSection(sectionId);
|
||||
// 更新 URL hash,但不触发页面滚动
|
||||
if (window.location.hash !== `#${sectionId}`) {
|
||||
window.history.replaceState(null, '', `#${sectionId}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [sections]);
|
||||
|
||||
// 计算侧边栏的位置
|
||||
useEffect(() => {
|
||||
const updateSidebarPosition = () => {
|
||||
const container = document.querySelector('.w-\\[95\\%\\]');
|
||||
if (container) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerLeft = containerRect.left;
|
||||
// 计算第一列的位置(24px padding + grid gap)
|
||||
setSidebarLeft(containerLeft + 24);
|
||||
}
|
||||
};
|
||||
|
||||
updateSidebarPosition();
|
||||
window.addEventListener('resize', updateSidebarPosition);
|
||||
return () => window.removeEventListener('resize', updateSidebarPosition);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-[95%] lg:w-[70%] max-w-none mx-auto p-4 lg:p-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8 lg:mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="p-3 bg-blue-100 rounded-xl">
|
||||
<Book className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900">使用帮助</h1>
|
||||
</div>
|
||||
<p className="text-base lg:text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
详细的部署指南和使用说明,帮助您快速上手文件传输工具
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* 返回首页按钮 - 桌面端固定定位 */}
|
||||
<div className="hidden lg:block">
|
||||
<div
|
||||
className="fixed bg-white rounded-xl shadow-lg border border-gray-200 p-4 z-20"
|
||||
style={{ left: `${sidebarLeft}px`, top: '2rem', width: '256px' }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors hover:bg-blue-50 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">返回首页</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 返回首页按钮 - 移动端固定定位 */}
|
||||
<div className="lg:hidden">
|
||||
<div className="fixed left-4 top-4 z-20">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg shadow-lg border border-gray-200 text-blue-600 hover:text-blue-700 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">返回首页</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏目录 - 桌面端固定定位 */}
|
||||
<div className="hidden lg:block">
|
||||
<div
|
||||
className="fixed w-64 bg-white rounded-xl shadow-lg border border-gray-200 p-6 max-h-[calc(100vh-10rem)] overflow-y-auto z-10"
|
||||
style={{ left: `${sidebarLeft}px`, top: '7rem' }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">目录</h3>
|
||||
<nav className="space-y-2">
|
||||
{sections.map((section) => (
|
||||
<div key={section.id}>
|
||||
<button
|
||||
onClick={() => scrollToSection(section.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors ${
|
||||
activeSection === section.id
|
||||
? 'bg-blue-50 text-blue-700 border border-blue-200'
|
||||
: 'hover:bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{section.icon}
|
||||
<span className="text-sm font-medium">{section.title}</span>
|
||||
<ChevronRight className="w-4 h-4 ml-auto" />
|
||||
</button>
|
||||
|
||||
{/* 子目录 */}
|
||||
{section.children && (
|
||||
<div className="ml-8 mt-1 space-y-1">
|
||||
{section.children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => scrollToSection(child.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-left transition-colors ${
|
||||
activeSection === child.id
|
||||
? 'bg-blue-100 text-blue-600 border border-blue-200'
|
||||
: 'hover:bg-gray-50 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{child.icon}
|
||||
<span className="text-xs text-gray-700">{child.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端目录 - 粘性定位 */}
|
||||
<div className="lg:hidden mb-6 mt-16">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-3">目录</h3>
|
||||
<nav className="space-y-1">
|
||||
{sections.map((section) => (
|
||||
<div key={section.id}>
|
||||
<button
|
||||
onClick={() => scrollToSection(section.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
|
||||
activeSection === section.id
|
||||
? 'bg-blue-50 text-blue-700 border border-blue-200'
|
||||
: 'hover:bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{section.icon}
|
||||
<span className="text-xs font-medium">{section.title}</span>
|
||||
<ChevronRight className="w-3 h-3 ml-auto" />
|
||||
</button>
|
||||
|
||||
{/* 子目录 */}
|
||||
{section.children && (
|
||||
<div className="ml-6 mt-1 space-y-1">
|
||||
{section.children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => scrollToSection(child.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-left transition-colors ${
|
||||
activeSection === child.id
|
||||
? 'bg-blue-100 text-blue-600 border border-blue-200'
|
||||
: 'hover:bg-gray-50 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{child.icon}
|
||||
<span className="text-xs text-gray-700">{child.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要内容 */}
|
||||
<div className="lg:ml-72 lg:mr-4">
|
||||
{/* 部署指南 */}
|
||||
<Section id="deployment" title="部署指南" icon={<Server className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<p className="text-gray-700 mb-6">
|
||||
文件传输工具支持多种部署方式,您可以根据自己的需求选择最适合的部署方案。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Docker 部署 */}
|
||||
<div className="scroll-mt-20" id="docker-deployment">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Container className="w-6 h-6 text-blue-600" />
|
||||
Docker 部署
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">方法一:使用 Docker Compose(推荐)</h4>
|
||||
<CodeBlock code={`git clone https://github.com/MatrixSeven/file-transfer-go.git
|
||||
cd file-transfer-go
|
||||
docker-compose up -d`} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">方法二:直接使用 Docker 镜像</h4>
|
||||
<CodeBlock code={`docker run -d -p 8080:8080 --name file-transfer-go matrixseven/file-transfer-go:latest`} />
|
||||
</div>
|
||||
|
||||
<InfoBox type="tip" title="部署提示">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Docker Compose 方式会自动处理依赖和网络配置</li>
|
||||
<li>服务启动后访问 <code className="bg-gray-100 px-2 py-1 rounded">http://localhost:8080</code></li>
|
||||
<li>可以通过修改 <code className="bg-gray-100 px-2 py-1 rounded">docker-compose.yml</code> 自定义端口</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二进制部署 */}
|
||||
<div className="scroll-mt-20" id="binary-deployment">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Download className="w-6 h-6 text-green-600" />
|
||||
二进制部署
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">下载预编译版本</h4>
|
||||
<p className="text-gray-700 mb-3">
|
||||
前往 <a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go/releases/"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub Releases 页面 <ExternalLink className="w-4 h-4" />
|
||||
</a> 下载对应系统的二进制包
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h5 className="font-semibold mb-2">支持的平台:</h5>
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
||||
<li>Linux (AMD64/ARM64)</li>
|
||||
<li>Windows (AMD64)</li>
|
||||
<li>macOS (AMD64/ARM64)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">启动服务</h4>
|
||||
<p className="text-gray-700 mb-3">下载后直接运行可执行文件即可:</p>
|
||||
<CodeBlock code={`# Linux/macOS
|
||||
chmod +x file-transfer-server-linux-amd64
|
||||
./file-transfer-server-linux-amd64
|
||||
|
||||
# Windows
|
||||
file-transfer-server-windows-amd64.exe`} />
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="注意事项">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>首次运行可能需要防火墙授权</li>
|
||||
<li>默认端口为 8080,可通过参数修改</li>
|
||||
<li>建议在生产环境使用 systemd 等进程管理工具</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自行构建 */}
|
||||
<div className="scroll-mt-20" id="build-deployment">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<Code className="w-6 h-6 text-purple-600" />
|
||||
自行构建
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">环境要求</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
||||
<li>Go 1.21 或更高版本</li>
|
||||
<li>Node.js 18 或更高版本</li>
|
||||
<li>Git</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-3">构建步骤</h4>
|
||||
<CodeBlock code={`git clone https://github.com/MatrixSeven/file-transfer-go.git
|
||||
cd file-transfer-go
|
||||
./build-fullstack.sh
|
||||
./dist/file-transfer-go`} />
|
||||
</div>
|
||||
|
||||
<InfoBox type="warning" title="构建注意事项">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>确保网络畅通,需要下载 Go 模块和 npm 包</li>
|
||||
<li>首次构建可能需要较长时间</li>
|
||||
<li>构建脚本会自动处理前后端的编译和打包</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 桌面共享权限 */}
|
||||
<Section id="desktop-share" title="桌面共享权限问题" icon={<Monitor className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5" />
|
||||
移动端无法共享桌面?
|
||||
</h3>
|
||||
<InfoBox type="error" title="移动端限制">
|
||||
<p>这是移动端浏览器的限制,WebRTC 没有在移动浏览器端实现获取桌面视频流的功能,所以这个能力无法在移动浏览器端实现。</p>
|
||||
<p className="mt-2 font-semibold">解决方案:请使用桌面设备进行屏幕共享。</p>
|
||||
</InfoBox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5" />
|
||||
PC 端无法共享桌面?
|
||||
</h3>
|
||||
|
||||
<InfoBox type="warning" title="HTTPS 要求">
|
||||
<p>如果是自行部署,无论是部署在局域网/公网,如果要实现桌面分享,需要必须保证服务访问地址是 TLS 加密,也就是 <code className="bg-gray-100 px-2 py-1 rounded">https</code> 方式访问。</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li><code className="bg-gray-100 px-2 py-1 rounded">localhost</code> 地址可以直接分享桌面</li>
|
||||
<li>其他地址需要配置反向代理(如 nginx)启用 HTTPS</li>
|
||||
<li>这是浏览器的安全限制,直接 IP 无法分享桌面</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
|
||||
<InfoBox type="tip" title="临时解决方案">
|
||||
<p>如果一定要用 IP+端口 的方式进行桌面分享,可以在浏览器设置中:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 mt-2">
|
||||
<li>打开浏览器设置</li>
|
||||
<li>搜索 WebRTC 相关设置</li>
|
||||
<li>开启 <code className="bg-gray-100 px-2 py-1 rounded">Anonymize local IPs exposed by WebRTC</code></li>
|
||||
<li>设置为 <code className="bg-gray-100 px-2 py-1 rounded">Enabled</code> 状态</li>
|
||||
</ol>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 端口配置 */}
|
||||
<Section id="port-config" title="端口配置" icon={<Settings className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">修改服务端口</h3>
|
||||
<p className="text-gray-700 mb-3">以 Linux 为例,将服务绑定到 18080 端口:</p>
|
||||
<CodeBlock code="./file-transfer-server-linux-amd64 -port 18080" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Docker 端口映射</h3>
|
||||
<p className="text-gray-700 mb-3">使用 Docker 时修改端口映射:</p>
|
||||
<CodeBlock code="docker run -d -p 18080:8080 matrixseven/file-transfer-go:latest" />
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="端口选择建议">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>避免使用系统保留端口(1-1024)</li>
|
||||
<li>确保选择的端口未被其他服务占用</li>
|
||||
<li>防火墙需要开放对应端口</li>
|
||||
<li>建议使用 8080, 3000, 8000 等常用端口</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 安全内网部署 */}
|
||||
<Section id="security" title="安全内网部署" icon={<Shield className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-6">
|
||||
<InfoBox type="warning" title="实验性功能">
|
||||
<p>以下方案理论可行,但未经充分验证,请在测试环境中验证后再用于生产。</p>
|
||||
</InfoBox>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">内网部署方案</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<h4 className="font-semibold mb-2">1. 部署内网 DNS 服务</h4>
|
||||
<p className="text-gray-700">配置内网域名解析,避免直接使用 IP 地址访问</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<h4 className="font-semibold mb-2">2. 配置 STUN/TURN 服务</h4>
|
||||
<p className="text-gray-700">部署内网 STUN/TURN 服务器,处理 NAT 穿透</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-blue-500 pl-4">
|
||||
<h4 className="font-semibold mb-2">3. 更新服务配置</h4>
|
||||
<p className="text-gray-700">在应用设置中配置自定义 STUN/TURN 服务器地址</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">STUN/TURN 服务器推荐</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
||||
<li><strong>Coturn</strong>:开源 TURN/STUN 服务器</li>
|
||||
<li><strong>Janus</strong>:WebRTC 网关,包含 STUN/TURN 功能</li>
|
||||
<li><strong>自建方案</strong>:基于 Docker 快速部署</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoBox type="tip" title="配置提示">
|
||||
<p>在应用的 设置 页面中,可以添加自定义 ICE 服务器:</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>STUN 服务器格式:<code className="bg-gray-100 px-2 py-1 rounded">stun:your-server.local:3478</code></li>
|
||||
<li>TURN 服务器格式:<code className="bg-gray-100 px-2 py-1 rounded">turn:your-server.local:3478</code></li>
|
||||
<li>TURN 服务器需要用户名和密码认证</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 数据传输说明 */}
|
||||
<Section id="data-transfer" title="数据传输机制" icon={<Wifi className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">传输方式</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-2 text-green-600">✓ 点对点传输</h4>
|
||||
<p className="text-sm text-gray-600">通过 WebRTC 建立直接连接,数据不经过服务器</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-2 text-blue-600">✓ 中继传输</h4>
|
||||
<p className="text-sm text-gray-600">当直连失败时,通过 TURN 服务器中继数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">传输流程</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">1</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">建立信令连接</h4>
|
||||
<p className="text-gray-600">通过 WebSocket 服务器交换连接信息</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">2</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">NAT 穿透</h4>
|
||||
<p className="text-gray-600">使用 STUN 服务器检测网络环境,尝试直连</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center font-semibold text-sm">3</div>
|
||||
<div>
|
||||
<h4 className="font-semibold">数据传输</h4>
|
||||
<p className="text-gray-600">建立 P2P 连接后直接传输,或通过 TURN 中继</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="隐私保护">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>所有文件数据通过点对点传输,服务器不存储任何文件内容</li>
|
||||
<li>房间码具有时效性,连接断开后自动失效</li>
|
||||
<li>支持端到端加密,确保传输安全</li>
|
||||
<li>即使使用 TURN 中继,数据也是加密传输的</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 交流反馈 */}
|
||||
<Section id="contact" title="交流反馈" icon={<Users className="w-6 h-6 text-blue-600" />}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">交流群组</h3>
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div className="flex-1">
|
||||
<p className="text-gray-700 mb-4">
|
||||
欢迎加入我们的交流群,获取最新更新、技术支持和经验分享:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
||||
<li>报告问题和建议</li>
|
||||
<li>获取使用帮助</li>
|
||||
<li>分享部署经验</li>
|
||||
<li>了解新功能动态</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<img
|
||||
src="https://cdn-img.luxika.cc//i/2025/09/04/68b8f0d135edc.png"
|
||||
alt="交流反馈群二维码"
|
||||
className="w-32 h-32 mx-auto rounded-lg"
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-2">扫码加入交流群</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">其他联系方式</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="w-5 h-5 text-blue-600" />
|
||||
GitHub Issues
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">提交 Bug 报告和功能请求</p>
|
||||
<a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go/issues"
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
前往 Issues 页面 →
|
||||
</a>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<h4 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<Book className="w-5 h-5 text-green-600" />
|
||||
项目文档
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">查看详细的技术文档</p>
|
||||
<a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go"
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
前往项目主页 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoBox type="tip" title="反馈建议">
|
||||
<p>为了更好地帮助您解决问题,请在反馈时提供:</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>详细的问题描述和复现步骤</li>
|
||||
<li>部署环境信息(Docker/二进制/自构建)</li>
|
||||
<li>浏览器类型和版本</li>
|
||||
<li>网络环境(内网/公网/NAT类型)</li>
|
||||
<li>相关的错误日志或截图</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
chuan-next/src/app/help/layout.tsx
Normal file
15
chuan-next/src/app/help/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '使用帮助 - 文件传输工具',
|
||||
description: '详细的部署指南和使用说明,帮助您快速上手文件传输工具',
|
||||
keywords: ['文件传输', '帮助文档', '部署指南', 'WebRTC', '使用说明'],
|
||||
}
|
||||
|
||||
export default function HelpLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return children
|
||||
}
|
||||
5
chuan-next/src/app/help/page.tsx
Normal file
5
chuan-next/src/app/help/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import HelpPage from '@/app/help/HelpPage'
|
||||
|
||||
export default function Help() {
|
||||
return <HelpPage />
|
||||
}
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
294
chuan-next/src/components/ConnectionStatus.tsx
Normal file
294
chuan-next/src/components/ConnectionStatus.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
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 {
|
||||
// 房间信息 - 只需要这个基本信息
|
||||
currentRoom?: { code: string; role: 'sender' | 'receiver' } | null;
|
||||
// 样式类名
|
||||
className?: string;
|
||||
// 紧凑模式
|
||||
compact?: boolean;
|
||||
// 内联模式 - 只返回状态文本,不包含UI结构
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
// 连接状态枚举
|
||||
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 {
|
||||
type: 'disconnected' as const,
|
||||
message: '未连接',
|
||||
detail: '尚未创建房间',
|
||||
};
|
||||
}
|
||||
|
||||
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 (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 (isJoinedRoom && !isPeerConnected) {
|
||||
return {
|
||||
type: 'room-ready' as const,
|
||||
message: '房间已创建',
|
||||
detail: '等待对方加入并建立P2P连接...',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
console.log('Unknown connection state:', connection);
|
||||
return {
|
||||
type: 'unknown' as const,
|
||||
message: '状态未知',
|
||||
detail: '',
|
||||
};
|
||||
};
|
||||
|
||||
// 状态颜色映射
|
||||
const getStatusColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'connected':
|
||||
return 'text-green-600';
|
||||
case 'connecting':
|
||||
case 'room-ready':
|
||||
return 'text-yellow-600';
|
||||
case 'websocket-ready':
|
||||
return 'text-orange-600';
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'disconnected':
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
// 状态图标
|
||||
const StatusIcon = ({ type, className = 'w-3 h-3' }: { type: string; className?: string }) => {
|
||||
const iconClass = cn('inline-block', className);
|
||||
|
||||
switch (type) {
|
||||
case 'connected':
|
||||
return <div className={cn(iconClass, 'bg-green-500 rounded-full')} />;
|
||||
case 'connecting':
|
||||
case 'room-ready':
|
||||
return (
|
||||
<div className={cn(iconClass, 'bg-yellow-500 rounded-full animate-pulse')} />
|
||||
);
|
||||
case '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':
|
||||
case 'unknown':
|
||||
default:
|
||||
return <div className={cn(iconClass, 'bg-gray-400 rounded-full')} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取连接状态文字描述
|
||||
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已连接' :
|
||||
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,
|
||||
};
|
||||
|
||||
// 如果是内联模式,只返回状态文字
|
||||
if (inline) {
|
||||
return <span className={cn('text-sm text-slate-600', className)}>{getConnectionStatusText(connection)}</span>;
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-slate-600 font-medium">WS</span>
|
||||
</div>
|
||||
<span className="text-slate-300 font-medium">|</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon
|
||||
type={connection.isPeerConnected ? 'connected' : 'disconnected'}
|
||||
className="w-2.5 h-2.5"
|
||||
/>
|
||||
<span className="text-sm text-slate-600 font-medium">RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div className="space-y-2">
|
||||
{/* 主要状态 */}
|
||||
<div className={cn('font-medium text-sm', getStatusColor(status.type))}>
|
||||
{status.message}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{status.detail}
|
||||
</div>
|
||||
|
||||
{/* 详细连接状态 */}
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 font-medium">WS</span>
|
||||
<StatusIcon
|
||||
type={connection.isWebSocketConnected ? 'connected' : 'disconnected'}
|
||||
className="w-2.5 h-2.5"
|
||||
/>
|
||||
<span className={cn(
|
||||
connection.isWebSocketConnected ? 'text-green-600' : 'text-slate-500'
|
||||
)}>
|
||||
{connection.isWebSocketConnected ? '已连接' : '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-slate-300">|</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 font-medium">RTC</span>
|
||||
<StatusIcon
|
||||
type={connection.isPeerConnected ? 'connected' : 'disconnected'}
|
||||
className="w-2.5 h-2.5"
|
||||
/>
|
||||
<span className={cn(
|
||||
connection.isPeerConnected ? 'text-green-600' : 'text-slate-500'
|
||||
)}>
|
||||
{connection.isPeerConnected ? '已连接' : '未连接'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,199 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useURLHandler } from '@/hooks/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Share, Monitor, Copy, Play, Square, Repeat, Users, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useDesktopShareBusiness } from '@/hooks/webrtc/useDesktopShareBusiness';
|
||||
import DesktopViewer from '@/components/DesktopViewer';
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay';
|
||||
import { Share, Monitor, AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import WebRTCDesktopReceiver from '@/components/webrtc/WebRTCDesktopReceiver';
|
||||
import WebRTCDesktopSender from '@/components/webrtc/WebRTCDesktopSender';
|
||||
|
||||
|
||||
interface DesktopShareProps {
|
||||
// 保留向后兼容性的props
|
||||
onStartSharing?: () => Promise<string>;
|
||||
onStopSharing?: () => Promise<void>;
|
||||
// 保留向后兼容性的props(已废弃,但保留接口)
|
||||
onJoinSharing?: (code: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function DesktopShare({
|
||||
onStartSharing,
|
||||
onStopSharing,
|
||||
onJoinSharing
|
||||
}: DesktopShareProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<'share' | 'view'>('share');
|
||||
const [inputCode, setInputCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
// 检测是否支持屏幕分享
|
||||
function useScreenShareSupport() {
|
||||
const [isSupported, setIsSupported] = useState(true);
|
||||
const [reason, setReason] = useState<string>('');
|
||||
|
||||
// 使用桌面共享业务逻辑
|
||||
const desktopShare = useDesktopShareBusiness();
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode');
|
||||
const type = searchParams.get('type');
|
||||
|
||||
if (type === 'desktop' && urlMode) {
|
||||
if (urlMode === 'send') {
|
||||
setMode('share');
|
||||
} else if (urlMode === 'receive') {
|
||||
setMode('view');
|
||||
const checkScreenShareSupport = async () => {
|
||||
try {
|
||||
// 首先检查是否存在 getDisplayMedia API
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||
setIsSupported(false);
|
||||
setReason('api-not-supported');
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
if (isMobile) {
|
||||
setIsSupported(false);
|
||||
setReason('mobile');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查安全上下文 - getDisplayMedia 需要安全上下文(HTTPS 或 localhost)
|
||||
if (!window.isSecureContext) {
|
||||
const isLocalhost = window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1' ||
|
||||
window.location.hostname === '[::1]';
|
||||
|
||||
if (!isLocalhost) {
|
||||
setIsSupported(false);
|
||||
setReason('insecure-context');
|
||||
return;
|
||||
}
|
||||
return
|
||||
}
|
||||
setIsSupported(true);
|
||||
setReason('');
|
||||
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error checking screen share support:', error);
|
||||
setIsSupported(false);
|
||||
setReason('unknown-error');
|
||||
}
|
||||
};
|
||||
|
||||
checkScreenShareSupport();
|
||||
}, []);
|
||||
|
||||
return { isSupported, reason };
|
||||
}
|
||||
|
||||
export default function DesktopShare({
|
||||
onJoinSharing
|
||||
}: DesktopShareProps) {
|
||||
const [mode, setMode] = useState<'share' | 'view'>('share');
|
||||
const { isSupported, reason } = useScreenShareSupport();
|
||||
|
||||
// 使用统一的URL处理器,带模式转换
|
||||
const { updateMode, getCurrentRoomCode } = useURLHandler({
|
||||
featureType: 'desktop',
|
||||
onModeChange: setMode,
|
||||
onAutoJoinRoom: onJoinSharing,
|
||||
modeConverter: {
|
||||
fromURL: (urlMode) => urlMode === 'send' ? 'share' : 'view',
|
||||
toURL: (componentMode) => componentMode === 'share' ? 'send' : 'receive'
|
||||
}
|
||||
}, [searchParams]);
|
||||
});
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: 'share' | 'view') => {
|
||||
setMode(newMode);
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set('type', 'desktop');
|
||||
currentUrl.searchParams.set('mode', newMode === 'share' ? 'send' : 'receive');
|
||||
router.replace(currentUrl.pathname + currentUrl.search);
|
||||
}, [router]);
|
||||
// 获取初始房间代码(用于接收者模式)
|
||||
const getInitialCode = useCallback(() => {
|
||||
const code = getCurrentRoomCode();
|
||||
console.log('[DesktopShare] getInitialCode 返回:', code);
|
||||
return code;
|
||||
}, [getCurrentRoomCode]);
|
||||
|
||||
// 复制房间代码
|
||||
const copyCode = useCallback(async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
showToast('房间代码已复制到剪贴板', 'success');
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
showToast('复制失败,请手动复制', 'error');
|
||||
// 连接状态变化处理 - 为了兼容现有的子组件接口,保留它
|
||||
const handleConnectionChange = useCallback((connection: { isConnected: boolean; isWebSocketConnected: boolean }) => {
|
||||
console.log('桌面共享连接状态变化:', connection);
|
||||
}, []);
|
||||
|
||||
// 获取提示信息
|
||||
const getWarningInfo = () => {
|
||||
switch (reason) {
|
||||
case 'mobile':
|
||||
return {
|
||||
title: '移动端不支持屏幕分享',
|
||||
message: '移动端浏览器不支持获取桌面视频流,请使用桌面设备进行屏幕共享。'
|
||||
};
|
||||
case 'api-not-supported':
|
||||
return {
|
||||
title: '浏览器不支持屏幕分享',
|
||||
message: '当前浏览器不支持 getDisplayMedia API,请使用支持屏幕分享的现代浏览器(如 Chrome、Firefox、Edge 等)。'
|
||||
};
|
||||
case 'insecure-context':
|
||||
return {
|
||||
title: '需要安全上下文',
|
||||
message: '屏幕分享功能需要在安全上下文中使用(HTTPS协议或localhost),当前环境不支持。'
|
||||
};
|
||||
case 'detection-failed':
|
||||
return {
|
||||
title: '检测屏幕分享支持失败',
|
||||
message: '无法检测屏幕分享支持情况,这可能是由于浏览器限制或权限问题。'
|
||||
};
|
||||
case 'unknown-error':
|
||||
return {
|
||||
title: '未知错误',
|
||||
message: '检测屏幕分享支持时发生未知错误,请尝试刷新页面或使用其他浏览器。'
|
||||
};
|
||||
case 'ip-http':
|
||||
return {
|
||||
title: '当前环境不支持屏幕分享',
|
||||
message: '使用IP地址访问时,浏览器要求HTTPS协议才能进行屏幕分享。请配置HTTPS或使用localhost访问。'
|
||||
};
|
||||
case 'non-https':
|
||||
return {
|
||||
title: '需要HTTPS协议',
|
||||
message: '屏幕分享功能需要在HTTPS环境下使用,请使用HTTPS协议访问或在本地环境测试。'
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
// 创建房间
|
||||
const handleCreateRoom = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShare] 用户点击创建房间');
|
||||
|
||||
const roomCode = await desktopShare.createRoom();
|
||||
console.log('[DesktopShare] 房间创建成功:', roomCode);
|
||||
|
||||
showToast(`房间创建成功!代码: ${roomCode}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 创建房间失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 开始桌面共享
|
||||
const handleStartSharing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShare] 用户点击开始桌面共享');
|
||||
|
||||
await desktopShare.startSharing();
|
||||
console.log('[DesktopShare] 桌面共享开始成功');
|
||||
|
||||
showToast('桌面共享已开始', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 开始桌面共享失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 切换桌面
|
||||
const handleSwitchDesktop = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShare] 用户点击切换桌面');
|
||||
|
||||
await desktopShare.switchDesktop();
|
||||
console.log('[DesktopShare] 桌面切换成功');
|
||||
|
||||
showToast('桌面切换成功', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 切换桌面失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 停止桌面共享
|
||||
const handleStopSharing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShare] 用户点击停止桌面共享');
|
||||
|
||||
await desktopShare.stopSharing();
|
||||
console.log('[DesktopShare] 桌面共享停止成功');
|
||||
|
||||
showToast('桌面共享已停止', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 停止桌面共享失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 加入观看
|
||||
const handleJoinViewing = useCallback(async () => {
|
||||
if (!inputCode.trim()) {
|
||||
showToast('请输入房间代码', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShare] 用户加入观看房间:', inputCode);
|
||||
|
||||
await desktopShare.joinSharing(inputCode.trim().toUpperCase());
|
||||
console.log('[DesktopShare] 加入观看成功');
|
||||
|
||||
showToast('已加入桌面共享', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 加入观看失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '加入观看失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, inputCode, showToast]);
|
||||
|
||||
// 停止观看
|
||||
const handleStopViewing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await desktopShare.stopViewing();
|
||||
showToast('已退出桌面共享', 'success');
|
||||
setInputCode('');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 停止观看失败:', error);
|
||||
showToast('退出失败', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 连接状态指示器
|
||||
const getConnectionStatus = () => {
|
||||
if (desktopShare.isConnecting) return { icon: Wifi, text: '连接中...', color: 'text-yellow-600' };
|
||||
if (desktopShare.isPeerConnected) return { icon: Wifi, text: 'P2P已连接', color: 'text-green-600' };
|
||||
if (desktopShare.isWebSocketConnected) return { icon: Users, text: '等待对方加入', color: 'text-blue-600' };
|
||||
return { icon: WifiOff, text: '未连接', color: 'text-gray-600' };
|
||||
};
|
||||
|
||||
const connectionStatus = getConnectionStatus();
|
||||
const warningInfo = getWarningInfo();
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 环境不支持提示 */}
|
||||
{!isSupported && warningInfo && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-amber-900 mb-1">{warningInfo.title}</h3>
|
||||
<p className="text-amber-800 text-sm mb-3">{warningInfo.message}</p>
|
||||
<Link
|
||||
href="/help#desktop-share"
|
||||
className="inline-flex items-center gap-2 text-sm text-amber-700 hover:text-amber-900 underline"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
查看详细解决方案
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模式选择器 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
|
||||
@@ -201,6 +167,7 @@ export default function DesktopShare({
|
||||
variant={mode === 'share' ? 'default' : 'ghost'}
|
||||
onClick={() => updateMode('share')}
|
||||
className="px-6 py-2 rounded-lg"
|
||||
disabled={!isSupported && mode === 'share'}
|
||||
>
|
||||
<Share className="w-4 h-4 mr-2" />
|
||||
共享桌面
|
||||
@@ -216,423 +183,15 @@ export default function DesktopShare({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'share' ? (
|
||||
/* 共享模式 */
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
{!desktopShare.connectionCode ? (
|
||||
// 创建房间前的界面
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">共享桌面</h2>
|
||||
<p className="text-sm text-slate-600">分享您的屏幕给其他人</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${desktopShare.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'}`}></div>
|
||||
<span className={desktopShare.isWebSocketConnected ? 'text-blue-600' : 'text-slate-600'}>WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${desktopShare.isPeerConnected ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'}`}></div>
|
||||
<span className={desktopShare.isPeerConnected ? 'text-emerald-600' : 'text-slate-600'}>RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center">
|
||||
<Monitor className="w-10 h-10 text-purple-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-800 mb-4">创建桌面共享房间</h3>
|
||||
<p className="text-slate-600 mb-8">创建房间后将生成分享码,等待接收方加入后即可开始桌面共享</p>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateRoom}
|
||||
disabled={isLoading || desktopShare.isConnecting}
|
||||
className="px-8 py-3 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-lg font-medium rounded-xl shadow-lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share className="w-5 h-5 mr-2" />
|
||||
创建桌面共享房间
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 房间已创建,显示取件码和等待界面
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">共享桌面</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{desktopShare.isPeerConnected ? '✅ 接收方已连接,现在可以开始共享桌面' :
|
||||
desktopShare.isWebSocketConnected ? '⏳ 房间已创建,等待接收方加入建立P2P连接' :
|
||||
'⚠️ 等待连接'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${desktopShare.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<span className={desktopShare.isWebSocketConnected ? 'text-blue-600' : 'text-red-600'}>WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${desktopShare.isPeerConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-400'}`}></div>
|
||||
<span className={desktopShare.isPeerConnected ? 'text-emerald-600' : 'text-orange-600'}>RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面共享控制区域 */}
|
||||
{desktopShare.canStartSharing && (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-slate-800 flex items-center">
|
||||
<Monitor className="w-5 h-5 mr-2" />
|
||||
桌面共享控制
|
||||
</h4>
|
||||
{desktopShare.isSharing && (
|
||||
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-medium">共享中</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!desktopShare.isSharing ? (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleStartSharing}
|
||||
disabled={isLoading || !desktopShare.isPeerConnected}
|
||||
className={`w-full px-8 py-3 text-lg font-medium rounded-xl shadow-lg ${
|
||||
desktopShare.isPeerConnected
|
||||
? 'bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Play className="w-5 h-5 mr-2" />
|
||||
{isLoading ? '启动中...' : '选择并开始共享桌面'}
|
||||
</Button>
|
||||
|
||||
{!desktopShare.isPeerConnected && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
等待接收方加入房间建立P2P连接...
|
||||
</p>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
|
||||
<span className="text-sm text-purple-600">正在等待连接</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center space-x-2 text-green-600 mb-4">
|
||||
<Play className="w-5 h-5" />
|
||||
<span className="font-semibold">桌面共享进行中</span>
|
||||
</div>
|
||||
<div className="flex justify-center space-x-3">
|
||||
<Button
|
||||
onClick={handleSwitchDesktop}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Repeat className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '切换中...' : '切换桌面'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStopSharing}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '停止中...' : '停止共享'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 取件码显示 - 和文件传输一致的风格 */}
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
{/* 左上角状态提示 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">房间码生成成功!</h3>
|
||||
<p className="text-sm text-slate-600">分享以下信息给观看方</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
|
||||
{/* 左侧:取件码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">房间代码</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent tracking-wider">
|
||||
{desktopShare.connectionCode}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => copyCode(desktopShare.connectionCode)}
|
||||
className="w-full px-4 py-2.5 bg-purple-500 hover:bg-purple-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
|
||||
>
|
||||
复制房间代码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
|
||||
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
|
||||
|
||||
{/* 右侧:二维码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">扫码观看</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<QRCodeDisplay
|
||||
value={`${typeof window !== 'undefined' ? window.location.origin : ''}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`}
|
||||
size={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
|
||||
使用手机扫码快速观看
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:观看链接 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
|
||||
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
|
||||
{`${typeof window !== 'undefined' ? window.location.origin : ''}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const link = `${window.location.origin}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
showToast('观看链接已复制', 'success');
|
||||
}}
|
||||
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
|
||||
>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* 观看模式 */
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
<div className="space-y-6">
|
||||
{!desktopShare.isViewing ? (
|
||||
// 输入房间代码界面 - 与文本消息风格一致
|
||||
<div>
|
||||
<div className="flex items-center mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">输入房间代码</h2>
|
||||
<p className="text-sm text-slate-600">请输入6位房间代码来观看桌面共享</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleJoinViewing(); }} className="space-y-4 sm:space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inputCode}
|
||||
onChange={(e) => setInputCode(e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase())}
|
||||
placeholder="请输入房间代码"
|
||||
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-purple-500 focus:ring-purple-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
|
||||
maxLength={6}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-xs sm:text-sm text-slate-500">
|
||||
{inputCode.length}/6 位
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={inputCode.length !== 6 || isLoading}
|
||||
className="w-full h-10 sm:h-12 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-base sm:text-lg font-medium rounded-xl shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>连接中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Monitor className="w-5 h-5" />
|
||||
<span>加入观看</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
// 已连接,显示桌面观看界面
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">桌面观看</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
<span className="text-emerald-600">✅ 已连接,正在观看桌面共享</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接成功状态 */}
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
|
||||
<h4 className="font-semibold text-emerald-800 mb-1">已连接到桌面共享房间</h4>
|
||||
<p className="text-emerald-700">房间代码: {inputCode}</p>
|
||||
</div>
|
||||
|
||||
{/* 观看中的控制面板 */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-white rounded-lg p-3 shadow-lg border flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span className="font-semibold">观看中</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleStopViewing}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-2" />
|
||||
{isLoading ? '退出中...' : '退出观看'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面显示区域 */}
|
||||
{desktopShare.remoteStream ? (
|
||||
<DesktopViewer
|
||||
stream={desktopShare.remoteStream}
|
||||
isConnected={desktopShare.isViewing}
|
||||
connectionCode={inputCode}
|
||||
onDisconnect={handleStopViewing}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-8 border border-slate-200">
|
||||
<div className="text-center">
|
||||
<Monitor className="w-16 h-16 mx-auto text-slate-400 mb-4" />
|
||||
<p className="text-slate-600 mb-2">等待接收桌面画面...</p>
|
||||
<p className="text-sm text-slate-500">发送方开始共享后,桌面画面将在这里显示</p>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2 mt-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-500"></div>
|
||||
<span className="text-sm text-purple-600">等待桌面流...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误显示 */}
|
||||
{desktopShare.error && (
|
||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-600 text-sm">{desktopShare.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 调试信息 */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setShowDebug(!showDebug)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showDebug ? '隐藏' : '显示'}调试信息
|
||||
</button>
|
||||
|
||||
{showDebug && (
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded text-xs text-gray-600 space-y-1">
|
||||
<div>WebSocket连接: {desktopShare.isWebSocketConnected ? '✅' : '❌'}</div>
|
||||
<div>P2P连接: {desktopShare.isPeerConnected ? '✅' : '❌'}</div>
|
||||
<div>房间代码: {desktopShare.connectionCode || '未创建'}</div>
|
||||
<div>共享状态: {desktopShare.isSharing ? '进行中' : '未共享'}</div>
|
||||
<div>观看状态: {desktopShare.isViewing ? '观看中' : '未观看'}</div>
|
||||
<div>等待对方: {desktopShare.isWaitingForPeer ? '是' : '否'}</div>
|
||||
<div>远程流: {desktopShare.remoteStream ? '已接收' : '无'}</div>
|
||||
</div>
|
||||
{/* 根据模式渲染对应的组件 */}
|
||||
<div>
|
||||
{mode === 'share' ? (
|
||||
<WebRTCDesktopSender onConnectionChange={handleConnectionChange} />
|
||||
) : (
|
||||
<WebRTCDesktopReceiver
|
||||
initialCode={getInitialCode()}
|
||||
onConnectionChange={handleConnectionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Monitor, Maximize, Minimize, Volume2, VolumeX, Settings, X } 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,17 +11,20 @@ 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);
|
||||
const hasAttemptedAutoplayRef = useRef(false);
|
||||
const [videoStats, setVideoStats] = useState<{
|
||||
resolution: string;
|
||||
fps: number;
|
||||
@@ -34,14 +37,104 @@ export default function DesktopViewer({
|
||||
if (videoRef.current && stream) {
|
||||
console.log('[DesktopViewer] 🎬 设置视频流,轨道数量:', stream.getTracks().length);
|
||||
stream.getTracks().forEach(track => {
|
||||
console.log('[DesktopViewer] 轨道详情:', track.kind, track.id, track.enabled, track.readyState);
|
||||
console.log('[DesktopViewer] 轨道详情:', track.kind, track.id, '启用:', track.enabled, '状态:', track.readyState);
|
||||
// 确保轨道已启用
|
||||
if (!track.enabled) {
|
||||
console.log('[DesktopViewer] 🔓 启用轨道:', track.id);
|
||||
track.enabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
videoRef.current.srcObject = stream;
|
||||
console.log('[DesktopViewer] ✅ 视频元素已设置流');
|
||||
videoRef.current.muted = true; // 确保默认静音
|
||||
console.log('[DesktopViewer] ✅ 视频元素已设置流并静音');
|
||||
|
||||
// 重置状态
|
||||
hasAttemptedAutoplayRef.current = false;
|
||||
setNeedsUserInteraction(false);
|
||||
setIsPlaying(false);
|
||||
|
||||
// 添加事件监听器来调试视频加载
|
||||
const video = videoRef.current;
|
||||
const handleLoadStart = () => console.log('[DesktopViewer] 📹 视频开始加载');
|
||||
const handleLoadedMetadata = () => {
|
||||
console.log('[DesktopViewer] 📹 视频元数据已加载');
|
||||
console.log('[DesktopViewer] 📹 视频尺寸:', video.videoWidth, 'x', video.videoHeight);
|
||||
};
|
||||
const handleCanPlay = () => {
|
||||
console.log('[DesktopViewer] 📹 视频可以开始播放');
|
||||
// 只在还未尝试过自动播放时才尝试
|
||||
if (!hasAttemptedAutoplayRef.current) {
|
||||
hasAttemptedAutoplayRef.current = true;
|
||||
video.play()
|
||||
.then(() => {
|
||||
console.log('[DesktopViewer] ✅ 视频自动播放成功');
|
||||
setIsPlaying(true);
|
||||
setNeedsUserInteraction(false);
|
||||
})
|
||||
.catch(e => {
|
||||
console.log('[DesktopViewer] 📹 自动播放被阻止,需要用户交互:', e.message);
|
||||
setIsPlaying(false);
|
||||
setNeedsUserInteraction(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
const handlePlay = () => {
|
||||
console.log('[DesktopViewer] 📹 视频开始播放');
|
||||
setIsPlaying(true);
|
||||
setNeedsUserInteraction(false);
|
||||
};
|
||||
const handlePause = () => {
|
||||
console.log('[DesktopViewer] 📹 视频暂停');
|
||||
setIsPlaying(false);
|
||||
};
|
||||
const handleError = (e: Event) => {
|
||||
console.error('[DesktopViewer] 📹 视频播放错误:', e);
|
||||
// 尝试重新加载流
|
||||
console.log('[DesktopViewer] 🔄 尝试重新加载视频流');
|
||||
setTimeout(() => {
|
||||
if (videoRef.current && stream) {
|
||||
videoRef.current.srcObject = null;
|
||||
videoRef.current.srcObject = stream;
|
||||
if (!hasAttemptedAutoplayRef.current) {
|
||||
hasAttemptedAutoplayRef.current = true;
|
||||
videoRef.current.play()
|
||||
.then(() => {
|
||||
console.log('[DesktopViewer] ✅ 重新加载后视频播放成功');
|
||||
setIsPlaying(true);
|
||||
setNeedsUserInteraction(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('[DesktopViewer] 📹 重新加载后自动播放仍被阻止:', err.message);
|
||||
setIsPlaying(false);
|
||||
setNeedsUserInteraction(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
video.addEventListener('loadstart', handleLoadStart);
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.addEventListener('canplay', handleCanPlay);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('error', handleError);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadstart', handleLoadStart);
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('canplay', handleCanPlay);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('error', handleError);
|
||||
};
|
||||
} else if (videoRef.current && !stream) {
|
||||
console.log('[DesktopViewer] ❌ 清除视频流');
|
||||
videoRef.current.srcObject = null;
|
||||
setIsPlaying(false);
|
||||
setNeedsUserInteraction(false);
|
||||
hasAttemptedAutoplayRef.current = false;
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
@@ -76,32 +169,58 @@ export default function DesktopViewer({
|
||||
const handleFullscreenChange = () => {
|
||||
const isCurrentlyFullscreen = !!document.fullscreenElement;
|
||||
setIsFullscreen(isCurrentlyFullscreen);
|
||||
|
||||
|
||||
if (isCurrentlyFullscreen) {
|
||||
// 全屏时自动隐藏控制栏,鼠标移动时显示
|
||||
setShowControls(false);
|
||||
} else {
|
||||
// 退出全屏时显示控制栏
|
||||
setShowControls(true);
|
||||
|
||||
// 延迟检查视频状态,确保全屏切换完成
|
||||
setTimeout(() => {
|
||||
if (videoRef.current && stream) {
|
||||
console.log('[DesktopViewer] 🔄 退出全屏,检查视频状态');
|
||||
|
||||
// 确保视频流正确设置
|
||||
const currentSrcObject = videoRef.current.srcObject;
|
||||
if (!currentSrcObject || currentSrcObject !== stream) {
|
||||
videoRef.current.srcObject = stream;
|
||||
}
|
||||
|
||||
// 检查视频是否暂停
|
||||
if (videoRef.current.paused) {
|
||||
console.log('[DesktopViewer] ⏸️ 退出全屏后视频已暂停,显示播放按钮');
|
||||
setIsPlaying(false);
|
||||
setNeedsUserInteraction(true);
|
||||
hasAttemptedAutoplayRef.current = true; // 标记已尝试过自动播放
|
||||
} else {
|
||||
console.log('[DesktopViewer] ▶️ 退出全屏后视频仍在播放');
|
||||
setIsPlaying(true);
|
||||
setNeedsUserInteraction(false);
|
||||
}
|
||||
}
|
||||
}, 200); // 延迟200ms确保全屏切换完成
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, []);
|
||||
}, [stream]);
|
||||
|
||||
// 鼠标移动处理(全屏时)
|
||||
const handleMouseMove = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
setShowControls(true);
|
||||
|
||||
|
||||
// 清除之前的定时器
|
||||
if (hideControlsTimeoutRef.current) {
|
||||
clearTimeout(hideControlsTimeoutRef.current);
|
||||
}
|
||||
|
||||
|
||||
// 3秒后自动隐藏控制栏
|
||||
hideControlsTimeoutRef.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
@@ -136,7 +255,7 @@ export default function DesktopViewer({
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
@@ -144,13 +263,43 @@ export default function DesktopViewer({
|
||||
|
||||
// 切换全屏
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
if (!containerRef.current) return;
|
||||
if (!videoRef.current) return;
|
||||
|
||||
try {
|
||||
if (isFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
// 退出全屏
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
// 退出iOS全屏模式
|
||||
if ((document as any).webkitExitFullscreen) {
|
||||
await (document as any).webkitExitFullscreen();
|
||||
}
|
||||
// 退出视频全屏模式
|
||||
if ((videoRef.current as any).webkitExitFullscreen) {
|
||||
await (videoRef.current as any).webkitExitFullscreen();
|
||||
}
|
||||
// 退出Android全屏模式
|
||||
if ((videoRef.current as any).exitFullscreen) {
|
||||
await (videoRef.current as any).exitFullscreen();
|
||||
}
|
||||
} else {
|
||||
await containerRef.current.requestFullscreen();
|
||||
// 进入标准全屏
|
||||
if (videoRef.current.requestFullscreen) {
|
||||
await videoRef.current.requestFullscreen();
|
||||
}
|
||||
// 进入iOS全屏模式
|
||||
else if ((videoRef.current as any).webkitRequestFullscreen) {
|
||||
await (videoRef.current as any).webkitRequestFullscreen();
|
||||
}
|
||||
// 进入iOS视频全屏模式
|
||||
else if ((videoRef.current as any).webkitEnterFullscreen) {
|
||||
await (videoRef.current as any).webkitEnterFullscreen();
|
||||
}
|
||||
// 进入Android全屏模式
|
||||
else if ((videoRef.current as any).requestFullscreen) {
|
||||
await (videoRef.current as any).requestFullscreen();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DesktopViewer] 全屏切换失败:', error);
|
||||
@@ -160,9 +309,22 @@ export default function DesktopViewer({
|
||||
// 退出全屏
|
||||
const exitFullscreen = useCallback(async () => {
|
||||
try {
|
||||
// 退出标准全屏
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
// 退出iOS全屏模式
|
||||
if ((document as any).webkitExitFullscreen) {
|
||||
await (document as any).webkitExitFullscreen();
|
||||
}
|
||||
// 退出视频全屏模式
|
||||
if (videoRef.current && (videoRef.current as any).webkitExitFullscreen) {
|
||||
await (videoRef.current as any).webkitExitFullscreen();
|
||||
}
|
||||
// 退出Android全屏模式
|
||||
if (videoRef.current && (videoRef.current as any).exitFullscreen) {
|
||||
await (videoRef.current as any).exitFullscreen();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DesktopViewer] 退出全屏失败:', error);
|
||||
}
|
||||
@@ -176,6 +338,21 @@ export default function DesktopViewer({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 手动播放视频
|
||||
const handleManualPlay = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play()
|
||||
.then(() => {
|
||||
console.log('[DesktopViewer] ✅ 手动播放成功');
|
||||
setIsPlaying(true);
|
||||
setNeedsUserInteraction(false);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('[DesktopViewer] ❌ 手动播放失败:', e);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -217,12 +394,25 @@ 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'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 需要用户交互的播放覆盖层 - 在视频暂停时显示 */}
|
||||
{((needsUserInteraction && !isPlaying) || (isConnected && !isPlaying && !needsUserInteraction && videoRef.current?.paused)) && (
|
||||
<div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center text-white z-10">
|
||||
<div className="text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-4 bg-white/20 rounded-full flex items-center justify-center hover:bg-white/30 transition-colors cursor-pointer" onClick={handleManualPlay}>
|
||||
<Play className="w-10 h-10 text-white ml-1" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">点击播放桌面共享</h3>
|
||||
<p className="text-sm opacity-75">视频已暂停,点击继续播放</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 连接状态覆盖层 */}
|
||||
{!isConnected && (
|
||||
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center text-white">
|
||||
@@ -236,27 +426,26 @@ export default function DesktopViewer({
|
||||
|
||||
{/* 控制栏 */}
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-all duration-300 ${
|
||||
showControls || !isFullscreen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4 transition-all duration-300 ${showControls || !isFullscreen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
{/* 左侧信息 */}
|
||||
<div className="flex items-center space-x-4 text-white text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<span>桌面共享中</span>
|
||||
<div className={`w-2 h-2 rounded-full ${isPlaying ? 'bg-green-500 animate-pulse' : 'bg-yellow-500'}`}></div>
|
||||
<span className="text-xs sm:text-sm">{isPlaying ? '桌面共享中' : needsUserInteraction ? '等待播放' : isConnected ? '已暂停' : '连接中'}</span>
|
||||
</div>
|
||||
{videoStats.resolution !== '0x0' && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-white/30"></div>
|
||||
<span>{videoStats.resolution}</span>
|
||||
<div className="w-px h-4 bg-white/30 hidden sm:block"></div>
|
||||
<span className="text-xs sm:text-sm hidden sm:block">{videoStats.resolution}</span>
|
||||
</>
|
||||
)}
|
||||
{connectionCode && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-white/30"></div>
|
||||
<span className="font-mono">{connectionCode}</span>
|
||||
<div className="w-px h-4 bg-white/30 hidden sm:block"></div>
|
||||
<span className="font-mono text-xs sm:text-sm hidden sm:block">{connectionCode}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -281,7 +470,7 @@ export default function DesktopViewer({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-white hover:bg-white/20"
|
||||
className="text-white hover:bg-white/20 hidden sm:flex"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -322,6 +511,19 @@ export default function DesktopViewer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 移动端浮动全屏按钮 - 在控制栏隐藏时显示 */}
|
||||
{!isFullscreen && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={toggleFullscreen}
|
||||
className="fixed bottom-20 right-4 z-40 md:hidden bg-black/60 text-white hover:bg-black/80 rounded-full p-3 shadow-lg"
|
||||
title="全屏"
|
||||
>
|
||||
<Maximize className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{stream && !isConnected && (
|
||||
<div className="absolute top-4 left-4 bg-black/60 text-white px-3 py-2 rounded-lg text-sm flex items-center space-x-2">
|
||||
@@ -333,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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Github } from 'lucide-react';
|
||||
import { Github, HelpCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
@@ -10,12 +11,10 @@ export default function Hero() {
|
||||
文件快传
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-slate-600 max-w-xl mx-auto leading-relaxed px-4 mb-3">
|
||||
安全、快速、简单的传输服务
|
||||
<br />
|
||||
<span className="text-xs sm:text-sm text-slate-500">基于WebRTC的端到端服务 - 无需注册,即传即用</span>
|
||||
</p>
|
||||
|
||||
{/* GitHub开源链接 */}
|
||||
{/* GitHub开源链接和帮助 */}
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go"
|
||||
@@ -24,21 +23,20 @@ export default function Hero() {
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs sm:text-sm text-slate-600 hover:text-slate-800 bg-slate-100 hover:bg-slate-200 rounded-full transition-colors duration-200 border border-slate-200 hover:border-slate-300"
|
||||
>
|
||||
<Github className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="font-medium">开源项目</span>
|
||||
<span className="font-medium">开源地址</span>
|
||||
</a>
|
||||
<span className="text-xs text-slate-400">|</span>
|
||||
<a
|
||||
href="https://github.com/MatrixSeven/file-transfer-go"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-slate-500 hover:text-slate-700 hover:underline transition-colors duration-200"
|
||||
|
||||
<Link
|
||||
href="/help"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs sm:text-sm text-blue-600 hover:text-blue-800 bg-blue-50 hover:bg-blue-100 rounded-full transition-colors duration-200 border border-blue-200 hover:border-blue-300"
|
||||
>
|
||||
https://github.com/MatrixSeven/file-transfer-go
|
||||
</a>
|
||||
<HelpCircle className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="font-medium">使用帮助</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className="w-64 sm:w-80 md:w-96 lg:w-[32rem] xl:w-[40rem] h-0.5 bg-gradient-to-r from-blue-400 via-purple-400 to-indigo-400 mx-auto mt-2 mb-2 opacity-60"></div>
|
||||
<div className="w-full max-w-2xl h-0.5 bg-gradient-to-r from-blue-400 via-purple-400 to-indigo-400 mx-auto mt-2 mb-2 opacity-60"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
123
chuan-next/src/components/RoomInfoDisplay.tsx
Normal file
123
chuan-next/src/components/RoomInfoDisplay.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface RoomInfoDisplayProps {
|
||||
// 房间信息
|
||||
code: string;
|
||||
link: string;
|
||||
|
||||
// 显示配置
|
||||
icon: LucideIcon;
|
||||
iconColor?: string; // 图标背景渐变色,如 'from-emerald-500 to-teal-500'
|
||||
codeColor?: string; // 代码文字渐变色,如 'from-emerald-600 to-teal-600'
|
||||
|
||||
// 文案配置
|
||||
title: string; // 如 "取件码生成成功!" 或 "房间码生成成功!"
|
||||
subtitle: string; // 如 "分享以下信息给接收方" 或 "分享以下信息给观看方"
|
||||
codeLabel: string; // 如 "取件码" 或 "房间代码"
|
||||
qrLabel: string; // 如 "扫码传输" 或 "扫码观看"
|
||||
copyButtonText: string; // 如 "复制取件码" 或 "复制房间代码"
|
||||
copyButtonColor?: string; // 复制按钮颜色,如 'bg-emerald-500 hover:bg-emerald-600'
|
||||
qrButtonText: string; // 如 "使用手机扫码快速访问" 或 "使用手机扫码快速观看"
|
||||
linkButtonText: string; // 如 "复制取件链接" 或 "复制观看链接"
|
||||
|
||||
// 事件回调
|
||||
onCopyCode: () => void;
|
||||
onCopyLink: () => void;
|
||||
|
||||
// 样式配置
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function RoomInfoDisplay({
|
||||
code,
|
||||
link,
|
||||
icon: Icon,
|
||||
iconColor = 'from-emerald-500 to-teal-500',
|
||||
codeColor = 'from-emerald-600 to-teal-600',
|
||||
title,
|
||||
subtitle,
|
||||
codeLabel,
|
||||
qrLabel,
|
||||
copyButtonText,
|
||||
copyButtonColor = 'bg-emerald-500 hover:bg-emerald-600',
|
||||
qrButtonText,
|
||||
linkButtonText,
|
||||
onCopyCode,
|
||||
onCopyLink,
|
||||
className = ''
|
||||
}: RoomInfoDisplayProps) {
|
||||
return (
|
||||
<div className={`border-t border-slate-200 pt-6 ${className}`}>
|
||||
{/* 左上角状态提示 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 bg-gradient-to-br ${iconColor} rounded-xl flex items-center justify-center`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">{title}</h3>
|
||||
<p className="text-sm text-slate-600">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间区域:代码 + 分隔线 + 二维码 */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
|
||||
{/* 左侧:代码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">{codeLabel}</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<div className={`text-2xl font-bold font-mono bg-gradient-to-r ${codeColor} bg-clip-text text-transparent tracking-wider`}>
|
||||
{code}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCopyCode}
|
||||
className={`w-full px-4 py-2.5 ${copyButtonColor} text-white rounded-lg font-medium shadow transition-all duration-200 mt-3`}
|
||||
>
|
||||
{copyButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
|
||||
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
|
||||
|
||||
{/* 右侧:二维码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">{qrLabel}</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<QRCodeDisplay
|
||||
value={link}
|
||||
size={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
|
||||
{qrButtonText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:链接 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
|
||||
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
|
||||
{link}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCopyLink}
|
||||
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
|
||||
>
|
||||
{linkButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
68
chuan-next/src/components/WeChatGroup.tsx
Normal file
68
chuan-next/src/components/WeChatGroup.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
export default function WeChatGroup() {
|
||||
return (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-6 sm:p-8 animate-fade-in-up">
|
||||
<div className="text-center">
|
||||
{/* 标题 */}
|
||||
<div className="mb-6">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-green-500 to-emerald-500 rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<Users className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800 mb-2">加入微信交流群</h2>
|
||||
<p className="text-slate-600 text-lg">
|
||||
佬们有意见/建议/bug反馈或者奇思妙想想来交流,可以扫码加入
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 二维码区域 */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
|
||||
{/* 微信群二维码 - 请将此区域替换为实际的二维码图片 */}
|
||||
<div className="relative">
|
||||
<img
|
||||
src="https://cdn-img.luxika.cc//i/2025/09/04/68b8f0d135edc.png"
|
||||
alt="微信群二维码"
|
||||
className="w-64 h-64 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 说明文字 */}
|
||||
<div className="bg-green-50 rounded-xl p-6 border border-green-200">
|
||||
<div className="text-sm text-green-700 space-y-2">
|
||||
<p className="text-base font-semibold text-green-800 mb-3">🎉 欢迎加入我们的交流群!</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-left">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>💬</span>
|
||||
<span>分享使用心得和建议</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>🐛</span>
|
||||
<span>反馈问题和bug</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>💡</span>
|
||||
<span>提出新功能想法</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>🤝</span>
|
||||
<span>与其他用户交流技术</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 额外信息 */}
|
||||
<div className="mt-4 text-xs text-slate-500">
|
||||
<p>群内禁止广告和无关内容,专注技术交流</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useSharedWebRTCManager } from '@/hooks/webrtc/useSharedWebRTCManager';
|
||||
import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness';
|
||||
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;
|
||||
@@ -20,30 +20,22 @@ interface FileInfo {
|
||||
}
|
||||
|
||||
export const WebRTCFileTransfer: React.FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 独立的文件状态
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [fileList, setFileList] = useState<FileInfo[]>([]);
|
||||
const [downloadedFiles, setDownloadedFiles] = useState<Map<string, File>>(new Map());
|
||||
// 基础状态
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
const [pickupCode, setPickupCode] = useState('');
|
||||
const [currentTransferFile, setCurrentTransferFile] = useState<{
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
progress: number;
|
||||
} | null>(null);
|
||||
|
||||
// 房间状态
|
||||
const [pickupCode, setPickupCode] = useState('');
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
|
||||
const [isJoiningRoom, setIsJoiningRoom] = useState(false); // 添加加入房间状态
|
||||
const urlProcessedRef = useRef(false); // 使用 ref 防止重复处理 URL
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 创建共享连接
|
||||
const connection = useSharedWebRTCManager();
|
||||
const connection = useConnectManager();
|
||||
const stableConnection = useMemo(() => connection, [connection.getConnectState().isConnected, connection.getConnectState().isConnecting, connection.getConnectState().isWebSocketConnected, connection.getConnectState().error]);
|
||||
|
||||
// 使用共享连接创建业务层
|
||||
const {
|
||||
@@ -59,244 +51,75 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
onFileReceived,
|
||||
onFileListReceived,
|
||||
onFileRequested,
|
||||
onFileProgress
|
||||
} = useFileTransferBusiness(connection);
|
||||
onFileProgress,
|
||||
clearSenderData
|
||||
} = useFileTransferBusiness(stableConnection);
|
||||
|
||||
// 加入房间 (接收模式) - 提前定义以供 useEffect 使用
|
||||
// 使用自定义 hooks
|
||||
const { syncFileListToReceiver } = useFileListSync({
|
||||
sendFileList,
|
||||
mode,
|
||||
pickupCode,
|
||||
isConnected,
|
||||
isPeerConnected: connection.getConnectState().isPeerConnected,
|
||||
getChannelState: () => connection.getConnectState().state
|
||||
});
|
||||
|
||||
const {
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
fileList,
|
||||
setFileList,
|
||||
downloadedFiles,
|
||||
setDownloadedFiles,
|
||||
handleFileSelect,
|
||||
clearFiles,
|
||||
resetFiles,
|
||||
updateFileStatus,
|
||||
updateFileProgress,
|
||||
clearSenderData: clearFileStateData
|
||||
} = useFileStateManager({
|
||||
mode,
|
||||
pickupCode,
|
||||
syncFileListToReceiver,
|
||||
isPeerConnected: connection.getConnectState().isPeerConnected
|
||||
});
|
||||
|
||||
const { joinRoom: originalJoinRoom } = useRoomConnection({
|
||||
connect,
|
||||
isConnecting,
|
||||
isConnected
|
||||
});
|
||||
|
||||
// 包装joinRoom函数以便设置pickupCode
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
console.log('=== 加入房间 ===');
|
||||
console.log('取件码:', code);
|
||||
|
||||
const trimmedCode = code.trim();
|
||||
|
||||
// 检查取件码格式
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位取件码', "error");
|
||||
return;
|
||||
}
|
||||
setPickupCode(code);
|
||||
await originalJoinRoom(code);
|
||||
}, [originalJoinRoom]);
|
||||
|
||||
// 防止重复调用 - 检查是否已经在连接或已连接
|
||||
if (isConnecting || isConnected || isJoiningRoom) {
|
||||
console.log('已在连接中或已连接,跳过重复的房间状态检查');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJoiningRoom(true);
|
||||
|
||||
try {
|
||||
// 先检查房间状态
|
||||
console.log('检查房间状态...');
|
||||
|
||||
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
let errorMessage = result.message || '房间不存在或已过期';
|
||||
if (result.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (result.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
showToast(errorMessage, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查发送方是否在线 (使用新的字段名)
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认取件码是否正确或联系发送方', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('房间状态检查通过,开始连接...');
|
||||
setPickupCode(trimmedCode);
|
||||
|
||||
connect(trimmedCode, 'receiver');
|
||||
|
||||
showToast(`正在连接到房间: ${trimmedCode}`, "success");
|
||||
} catch (error) {
|
||||
console.error('检查房间状态失败:', error);
|
||||
let errorMessage = '检查房间状态失败';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
errorMessage = '网络连接失败,请检查网络状况';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = '请求超时,请重试';
|
||||
} else if (error.message.includes('HTTP 404')) {
|
||||
errorMessage = '房间不存在,请检查取件码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
errorMessage = '服务器错误,请稍后重试';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsJoiningRoom(false); // 重置加入房间状态
|
||||
}
|
||||
}, [isConnecting, isConnected, isJoiningRoom, showToast, connect]); // 添加isJoiningRoom依赖
|
||||
const { updateMode } = useURLHandler({
|
||||
featureType: 'webrtc',
|
||||
onModeChange: setMode,
|
||||
onAutoJoinRoom: joinRoom
|
||||
});
|
||||
|
||||
// 从URL参数中获取初始模式(仅在首次加载时处理)
|
||||
useEffect(() => {
|
||||
// 使用 ref 确保只处理一次,避免严格模式的重复调用
|
||||
if (urlProcessedRef.current) {
|
||||
console.log('URL已处理过,跳过重复处理');
|
||||
return;
|
||||
}
|
||||
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type');
|
||||
const code = searchParams.get('code');
|
||||
|
||||
// 只在首次加载且URL中有webrtc类型时处理
|
||||
if (!hasProcessedInitialUrl && type === 'webrtc' && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
console.log('=== 处理初始URL参数 ===');
|
||||
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
|
||||
|
||||
// 立即标记为已处理,防止重复
|
||||
urlProcessedRef.current = true;
|
||||
|
||||
setMode(urlMode);
|
||||
setHasProcessedInitialUrl(true);
|
||||
|
||||
if (code && urlMode === 'receive') {
|
||||
console.log('URL中有取件码,自动加入房间');
|
||||
// 防止重复调用 - 检查连接状态和加入房间状态
|
||||
if (!isConnecting && !isConnected && !isJoiningRoom) {
|
||||
// 直接调用异步函数,不依赖 joinRoom
|
||||
const autoJoinRoom = async () => {
|
||||
const trimmedCode = code.trim();
|
||||
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位取件码', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJoiningRoom(true);
|
||||
|
||||
try {
|
||||
console.log('检查房间状态...');
|
||||
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
let errorMessage = result.message || '房间不存在或已过期';
|
||||
if (result.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (result.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
showToast(errorMessage, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认取件码是否正确或联系发送方', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('房间状态检查通过,开始连接...');
|
||||
setPickupCode(trimmedCode);
|
||||
connect(trimmedCode, 'receiver');
|
||||
showToast(`正在连接到房间: ${trimmedCode}`, "success");
|
||||
} catch (error) {
|
||||
console.error('检查房间状态失败:', error);
|
||||
let errorMessage = '检查房间状态失败';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
errorMessage = '网络连接失败,请检查网络状况';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = '请求超时,请重试';
|
||||
} else if (error.message.includes('HTTP 404')) {
|
||||
errorMessage = '房间不存在,请检查取件码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
errorMessage = '服务器错误,请稍后重试';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsJoiningRoom(false);
|
||||
}
|
||||
};
|
||||
|
||||
autoJoinRoom();
|
||||
} else {
|
||||
console.log('已在连接中或加入房间中,跳过重复处理');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [searchParams, hasProcessedInitialUrl, isConnecting, isConnected, isJoiningRoom, showToast, connect]); // 添加isJoiningRoom依赖
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: 'send' | 'receive') => {
|
||||
console.log('=== 手动切换模式 ===');
|
||||
console.log('新模式:', newMode);
|
||||
|
||||
setMode(newMode);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'webrtc');
|
||||
params.set('mode', newMode);
|
||||
|
||||
// 如果切换到发送模式,移除code参数
|
||||
if (newMode === 'send') {
|
||||
params.delete('code');
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
useConnectionState({
|
||||
isWebSocketConnected,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
error: error || '',
|
||||
pickupCode,
|
||||
fileListLength: fileList.length,
|
||||
currentTransferFile,
|
||||
setCurrentTransferFile,
|
||||
updateFileListStatus: setFileList
|
||||
});
|
||||
|
||||
// 生成文件ID
|
||||
const generateFileId = () => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
};
|
||||
|
||||
// 文件选择处理
|
||||
const handleFileSelect = (files: File[]) => {
|
||||
console.log('=== 文件选择 ===');
|
||||
console.log('新文件:', files.map(f => f.name));
|
||||
|
||||
// 更新选中的文件
|
||||
setSelectedFiles(prev => [...prev, ...files]);
|
||||
|
||||
// 创建对应的文件信息
|
||||
const newFileInfos: FileInfo[] = files.map(file => ({
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready',
|
||||
progress: 0
|
||||
}));
|
||||
|
||||
setFileList(prev => {
|
||||
const updatedList = [...prev, ...newFileInfos];
|
||||
console.log('更新后的文件列表:', updatedList);
|
||||
|
||||
// 如果P2P连接已建立,立即同步文件列表
|
||||
if (isConnected && connection.isPeerConnected && pickupCode) {
|
||||
console.log('立即同步文件列表到对端');
|
||||
setTimeout(() => sendFileList(updatedList), 100);
|
||||
}
|
||||
|
||||
return updatedList;
|
||||
});
|
||||
};
|
||||
|
||||
// 创建房间 (发送模式)
|
||||
const generateCode = async () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
@@ -308,21 +131,14 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
console.log('=== 创建房间 ===');
|
||||
console.log('选中文件数:', selectedFiles.length);
|
||||
|
||||
// 创建后端房间
|
||||
// 创建后端房间 - 简化版本,不发送无用的文件信息
|
||||
const response = await fetch('/api/create-room', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'file',
|
||||
files: selectedFiles.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
}))
|
||||
}),
|
||||
// 不再发送文件列表,因为后端不使用这些信息
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -332,12 +148,12 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
}
|
||||
|
||||
const code = data.code;
|
||||
setPickupCode(code);
|
||||
|
||||
console.log('房间创建成功,取件码:', code);
|
||||
|
||||
// 连接WebRTC作为发送方
|
||||
connect(code, 'sender');
|
||||
// 先连接WebRTC作为发送方,再设置取件码
|
||||
// 这样可以确保UI状态与连接状态同步
|
||||
await connect(code, 'sender');
|
||||
setPickupCode(code);
|
||||
|
||||
showToast(`房间创建成功,取件码: ${code}`, "success");
|
||||
} catch (error) {
|
||||
@@ -369,23 +185,17 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
|
||||
// 清空状态
|
||||
setPickupCode('');
|
||||
setFileList([]);
|
||||
setDownloadedFiles(new Map());
|
||||
resetFiles();
|
||||
|
||||
// 如果是接收模式,更新URL移除code参数
|
||||
if (mode === 'receive') {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('code');
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
// 如果是接收模式,需要手动更新URL
|
||||
// URL处理逻辑已经移到 hook 中
|
||||
};
|
||||
|
||||
// 处理文件列表更新
|
||||
// 处理文件列表更新
|
||||
useEffect(() => {
|
||||
const cleanup = onFileListReceived((fileInfos: FileInfo[]) => {
|
||||
console.log('=== 收到文件列表更新 ===');
|
||||
console.log('文件列表:', fileInfos);
|
||||
console.log('当前模式:', mode);
|
||||
|
||||
if (mode === 'receive') {
|
||||
setFileList(fileInfos);
|
||||
@@ -395,6 +205,100 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
return cleanup;
|
||||
}, [onFileListReceived, mode]);
|
||||
|
||||
// 处理文件接收
|
||||
useEffect(() => {
|
||||
const cleanup = onFileReceived((fileData: { id: string; file: File }) => {
|
||||
console.log('=== 接收到文件 ===');
|
||||
console.log('文件:', fileData.file.name, 'ID:', fileData.id);
|
||||
|
||||
// 更新下载的文件
|
||||
setDownloadedFiles(prev => new Map(prev.set(fileData.id, fileData.file)));
|
||||
|
||||
// 更新文件状态
|
||||
updateFileStatus(fileData.id, 'completed', 100);
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileReceived, updateFileStatus]);
|
||||
|
||||
// 监听文件级别的进度更新
|
||||
useEffect(() => {
|
||||
const cleanup = onFileProgress((progressInfo) => {
|
||||
// 检查连接状态,如果连接断开则忽略进度更新
|
||||
if (!isConnected || error) {
|
||||
console.log('连接已断开,忽略进度更新:', progressInfo.fileName);
|
||||
return;
|
||||
}
|
||||
// 更新当前传输文件信息
|
||||
setCurrentTransferFile({
|
||||
fileId: progressInfo.fileId,
|
||||
fileName: progressInfo.fileName,
|
||||
progress: progressInfo.progress
|
||||
});
|
||||
|
||||
// 更新文件进度
|
||||
updateFileProgress(progressInfo.fileId, progressInfo.fileName, progressInfo.progress);
|
||||
|
||||
// 当传输完成时清理
|
||||
if (progressInfo.progress >= 100 && mode === 'send') {
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileProgress, mode, isConnected, error, updateFileProgress]);
|
||||
|
||||
// 处理文件请求(发送方监听)
|
||||
useEffect(() => {
|
||||
const cleanup = onFileRequested((fileId: string, fileName: string) => {
|
||||
console.log('=== 收到文件请求 ===');
|
||||
console.log('文件:', fileName, 'ID:', fileId, '当前模式:', mode);
|
||||
|
||||
if (mode === 'send') {
|
||||
// 检查连接状态
|
||||
if (!isConnected || error) {
|
||||
console.log('连接已断开,无法发送文件');
|
||||
showToast('连接已断开,无法发送文件', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('当前选中的文件列表:', selectedFiles.map(f => f.name));
|
||||
|
||||
// 在发送方的selectedFiles中查找对应文件
|
||||
const file = selectedFiles.find(f => f.name === fileName);
|
||||
|
||||
if (!file) {
|
||||
console.error('找不到匹配的文件:', fileName);
|
||||
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
|
||||
showToast(`无法找到文件: ${fileName}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
|
||||
|
||||
// 更新发送方文件状态为downloading - 统一使用updateFileStatus
|
||||
updateFileStatus(fileId, 'downloading', 0);
|
||||
|
||||
// 发送文件
|
||||
try {
|
||||
sendFile(file, fileId);
|
||||
|
||||
// 移除不必要的Toast - 传输开始状态在UI中已经显示
|
||||
} catch (sendError) {
|
||||
console.error('发送文件失败:', sendError);
|
||||
showToast(`发送文件失败: ${fileName}`, "error");
|
||||
|
||||
// 重置文件状态 - 统一使用updateFileStatus
|
||||
updateFileStatus(fileId, 'ready', 0);
|
||||
}
|
||||
} else {
|
||||
console.warn('接收模式下收到文件请求,忽略');
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error, showToast, updateFileStatus]);
|
||||
|
||||
// 处理连接错误
|
||||
const [lastError, setLastError] = useState<string>('');
|
||||
useEffect(() => {
|
||||
@@ -439,156 +343,48 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
}
|
||||
}, [error, mode, showToast, lastError]);
|
||||
|
||||
// 处理文件接收
|
||||
|
||||
|
||||
// 监听连接状态变化和清理传输状态
|
||||
useEffect(() => {
|
||||
const cleanup = onFileReceived((fileData: { id: string; file: File }) => {
|
||||
console.log('=== 接收到文件 ===');
|
||||
console.log('文件:', fileData.file.name, 'ID:', fileData.id);
|
||||
|
||||
// 更新下载的文件
|
||||
setDownloadedFiles(prev => new Map(prev.set(fileData.id, fileData.file)));
|
||||
|
||||
// 更新文件状态
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.id === fileData.id
|
||||
? { ...item, status: 'completed' as const, progress: 100 }
|
||||
: item
|
||||
));
|
||||
|
||||
// 移除不必要的Toast - 文件完成状态在UI中已经显示
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileReceived]);
|
||||
|
||||
// 监听文件级别的进度更新
|
||||
useEffect(() => {
|
||||
const cleanup = onFileProgress((progressInfo) => {
|
||||
// 检查连接状态,如果连接断开则忽略进度更新
|
||||
if (!isConnected || error) {
|
||||
console.log('连接已断开,忽略进度更新:', progressInfo.fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== 文件进度更新 ===');
|
||||
console.log('文件:', progressInfo.fileName, 'ID:', progressInfo.fileId, '进度:', progressInfo.progress);
|
||||
|
||||
// 更新当前传输文件信息
|
||||
setCurrentTransferFile({
|
||||
fileId: progressInfo.fileId,
|
||||
fileName: progressInfo.fileName,
|
||||
progress: progressInfo.progress
|
||||
});
|
||||
|
||||
// 更新文件列表中对应文件的进度
|
||||
setFileList(prev => prev.map(item => {
|
||||
if (item.id === progressInfo.fileId || item.name === progressInfo.fileName) {
|
||||
const newProgress = progressInfo.progress;
|
||||
const newStatus = newProgress >= 100 ? 'completed' as const : 'downloading' as const;
|
||||
|
||||
console.log(`更新文件 ${item.name} 进度: ${item.progress} -> ${newProgress}`);
|
||||
return { ...item, progress: newProgress, status: newStatus };
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
|
||||
// 当传输完成时显示提示
|
||||
if (progressInfo.progress >= 100 && mode === 'send') {
|
||||
// 移除不必要的Toast - 传输完成状态在UI中已经显示
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileProgress, mode, isConnected, error]);
|
||||
|
||||
// 实时更新传输进度(旧逻辑 - 删除)
|
||||
// useEffect(() => {
|
||||
// ...已删除的旧代码...
|
||||
// }, [...]);
|
||||
|
||||
// 处理文件请求(发送方监听)
|
||||
useEffect(() => {
|
||||
const cleanup = onFileRequested((fileId: string, fileName: string) => {
|
||||
console.log('=== 收到文件请求 ===');
|
||||
console.log('文件:', fileName, 'ID:', fileId, '当前模式:', mode);
|
||||
|
||||
if (mode === 'send') {
|
||||
// 检查连接状态
|
||||
if (!isConnected || error) {
|
||||
console.log('连接已断开,无法发送文件');
|
||||
showToast('连接已断开,无法发送文件', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('当前选中的文件列表:', selectedFiles.map(f => f.name));
|
||||
|
||||
// 在发送方的selectedFiles中查找对应文件
|
||||
const file = selectedFiles.find(f => f.name === fileName);
|
||||
|
||||
if (!file) {
|
||||
console.error('找不到匹配的文件:', fileName);
|
||||
console.log('可用文件:', selectedFiles.map(f => `${f.name} (${f.size} bytes)`));
|
||||
showToast(`无法找到文件: ${fileName}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('找到匹配文件,开始发送:', file.name, 'ID:', fileId, '文件大小:', file.size);
|
||||
|
||||
// 更新发送方文件状态为downloading
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.id === fileId || item.name === fileName
|
||||
? { ...item, status: 'downloading' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
|
||||
// 发送文件
|
||||
try {
|
||||
sendFile(file, fileId);
|
||||
|
||||
// 移除不必要的Toast - 传输开始状态在UI中已经显示
|
||||
} catch (sendError) {
|
||||
console.error('发送文件失败:', sendError);
|
||||
showToast(`发送文件失败: ${fileName}`, "error");
|
||||
|
||||
// 重置文件状态
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.id === fileId || item.name === fileName
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
}
|
||||
} else {
|
||||
console.warn('接收模式下收到文件请求,忽略');
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [onFileRequested, mode, selectedFiles, sendFile, isConnected, error]);
|
||||
|
||||
// 监听WebSocket连接状态变化
|
||||
useEffect(() => {
|
||||
console.log('=== WebSocket状态变化 ===');
|
||||
console.log('=== 连接状态变化 ===');
|
||||
console.log('WebSocket连接状态:', isWebSocketConnected);
|
||||
console.log('WebRTC连接状态:', isConnected);
|
||||
console.log('连接中状态:', isConnecting);
|
||||
|
||||
// 只有在之前已经建立过连接,现在断开的情况下才显示断开提示
|
||||
// 避免在初始连接时误报断开
|
||||
if (!isWebSocketConnected && !isConnected && !isConnecting && pickupCode) {
|
||||
// 增加额外检查:只有在之前曾经连接成功过的情况下才显示断开提示
|
||||
// 通过检查是否有文件列表来判断是否曾经连接过
|
||||
if (fileList.length > 0 || currentTransferFile) {
|
||||
showToast('与服务器的连接已断开,请重新连接', "error");
|
||||
// 当连接断开或有错误时,清理所有传输状态
|
||||
const shouldCleanup = (!isWebSocketConnected && !isConnected && !isConnecting && pickupCode) ||
|
||||
((!isConnected && !isConnecting) || error);
|
||||
|
||||
if (shouldCleanup) {
|
||||
const hasCurrentTransfer = !!currentTransferFile;
|
||||
const hasFileList = fileList.length > 0;
|
||||
|
||||
// 只有在之前有连接活动时才显示断开提示和清理状态
|
||||
if (hasFileList || hasCurrentTransfer) {
|
||||
if (!isWebSocketConnected && pickupCode) {
|
||||
showToast('与服务器的连接已断开,请重新连接', "error");
|
||||
}
|
||||
|
||||
// 清理传输状态
|
||||
console.log('WebSocket断开,清理传输状态');
|
||||
setCurrentTransferFile(null);
|
||||
setFileList(prev => prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
console.log('连接断开,清理传输状态');
|
||||
|
||||
if (currentTransferFile) {
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
|
||||
// 重置所有正在下载的文件状态
|
||||
setFileList(prev => {
|
||||
const hasDownloadingFiles = prev.some(item => item.status === 'downloading');
|
||||
if (hasDownloadingFiles) {
|
||||
console.log('重置正在传输的文件状态');
|
||||
return prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,34 +393,9 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
console.log('WebSocket已连接,正在建立P2P连接...');
|
||||
}
|
||||
|
||||
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, showToast, fileList.length, currentTransferFile]);
|
||||
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, error, showToast, currentTransferFile, fileList.length]);
|
||||
|
||||
// 监听连接状态变化,清理传输状态
|
||||
useEffect(() => {
|
||||
// 当连接断开或有错误时,清理所有传输状态
|
||||
if ((!isConnected && !isConnecting) || error) {
|
||||
if (currentTransferFile) {
|
||||
console.log('连接断开,清理当前传输文件状态:', currentTransferFile.fileName);
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
|
||||
// 重置所有正在下载的文件状态
|
||||
setFileList(prev => {
|
||||
const hasDownloadingFiles = prev.some(item => item.status === 'downloading');
|
||||
if (hasDownloadingFiles) {
|
||||
console.log('重置正在传输的文件状态');
|
||||
return prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [isConnected, isConnecting, error, currentTransferFile]);
|
||||
|
||||
// 监听连接状态变化并提供用户反馈
|
||||
// 监听连接状态变化并提供日志
|
||||
useEffect(() => {
|
||||
console.log('=== WebRTC连接状态变化 ===');
|
||||
console.log('连接状态:', {
|
||||
@@ -636,65 +407,115 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
selectedFilesCount: selectedFiles.length,
|
||||
fileListCount: fileList.length
|
||||
});
|
||||
|
||||
// 连接成功时的提示
|
||||
if (isConnected && !isConnecting) {
|
||||
if (mode === 'send') {
|
||||
// 移除不必要的Toast - 连接状态在UI中已经显示
|
||||
} else {
|
||||
// 移除不必要的Toast - 连接状态在UI中已经显示
|
||||
}
|
||||
}
|
||||
|
||||
// 连接中的状态
|
||||
if (isConnecting && pickupCode) {
|
||||
console.log('正在建立WebRTC连接...');
|
||||
}
|
||||
|
||||
// 只有在P2P连接建立且没有错误时才发送文件列表
|
||||
if (isConnected && connection.isPeerConnected && !error && pickupCode && mode === 'send' && selectedFiles.length > 0) {
|
||||
// 确保有文件列表
|
||||
if (fileList.length === 0) {
|
||||
console.log('创建文件列表并发送...');
|
||||
const newFileInfos: FileInfo[] = selectedFiles.map(file => ({
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready',
|
||||
progress: 0
|
||||
}));
|
||||
setFileList(newFileInfos);
|
||||
// 延迟发送,确保数据通道已准备好
|
||||
setTimeout(() => {
|
||||
if (isConnected && connection.isPeerConnected && !error) { // 再次检查连接状态
|
||||
sendFileList(newFileInfos);
|
||||
}
|
||||
}, 500);
|
||||
} else if (fileList.length > 0) {
|
||||
console.log('发送现有文件列表...');
|
||||
// 延迟发送,确保数据通道已准备好
|
||||
setTimeout(() => {
|
||||
if (isConnected && connection.isPeerConnected && !error) { // 再次检查连接状态
|
||||
sendFileList(fileList);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, [isConnected, connection.isPeerConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, error]);
|
||||
}, [isConnected, connection.getConnectState().isPeerConnected, isConnecting, isWebSocketConnected, pickupCode, mode, selectedFiles.length, fileList.length]);
|
||||
|
||||
// 监听P2P连接建立,自动发送文件列表
|
||||
// 监听P2P连接建立时的状态变化
|
||||
useEffect(() => {
|
||||
if (connection.isPeerConnected && mode === 'send' && fileList.length > 0) {
|
||||
console.log('P2P连接已建立,发送文件列表...');
|
||||
// 稍微延迟一下,确保数据通道完全准备好
|
||||
setTimeout(() => {
|
||||
if (connection.isPeerConnected && connection.getChannelState() === 'open') {
|
||||
sendFileList(fileList);
|
||||
}
|
||||
}, 200);
|
||||
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, fileList.length, sendFileList]);
|
||||
}, [connection.getConnectState().isPeerConnected, connection.getConnectState().isDataChannelConnected, connection.getConnectState().state, connection.getConnectState().isConnected, mode, fileList.length, syncFileListToReceiver]);
|
||||
|
||||
// 监听fileList大小变化并同步
|
||||
useEffect(() => {
|
||||
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.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(() => {
|
||||
// 只有在发送模式下且已有房间时才处理文件列表同步
|
||||
if (mode !== 'send' || !pickupCode) return;
|
||||
|
||||
console.log('=== selectedFiles变化,同步文件列表 ===', {
|
||||
selectedFilesCount: selectedFiles.length,
|
||||
fileListCount: fileList.length,
|
||||
selectedFileNames: selectedFiles.map(f => f.name)
|
||||
});
|
||||
|
||||
// 根据selectedFiles创建新的文件信息列表
|
||||
const newFileInfos: FileInfo[] = selectedFiles.map(file => {
|
||||
// 尝试找到现有的文件信息,保持已有的状态
|
||||
const existingFileInfo = fileList.find(info => info.name === file.name && info.size === file.size);
|
||||
return existingFileInfo || {
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready' as const,
|
||||
progress: 0
|
||||
};
|
||||
});
|
||||
|
||||
// 检查文件列表是否真正发生变化
|
||||
const fileListChanged =
|
||||
newFileInfos.length !== fileList.length ||
|
||||
newFileInfos.some(newFile =>
|
||||
!fileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
|
||||
);
|
||||
|
||||
if (fileListChanged) {
|
||||
console.log('文件列表发生变化,更新:', {
|
||||
before: fileList.map(f => f.name),
|
||||
after: newFileInfos.map(f => f.name)
|
||||
});
|
||||
|
||||
setFileList(newFileInfos);
|
||||
}
|
||||
}, [selectedFiles, mode, pickupCode]);
|
||||
|
||||
// 请求下载文件(接收方调用)
|
||||
const requestFile = (fileId: string) => {
|
||||
@@ -778,17 +599,6 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// 清空文件
|
||||
const clearFiles = () => {
|
||||
console.log('=== 清空文件 ===');
|
||||
setSelectedFiles([]);
|
||||
setFileList([]);
|
||||
// 只有在P2P连接建立且数据通道准备好时才发送清空消息
|
||||
if (isConnected && connection.isPeerConnected && connection.getChannelState() === 'open' && pickupCode) {
|
||||
sendFileList([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载文件到本地
|
||||
const downloadFile = (fileId: string) => {
|
||||
const file = downloadedFiles.get(fileId);
|
||||
@@ -846,6 +656,8 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
|
||||
{mode === 'send' ? (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
{/* 连接状态显示 */}
|
||||
|
||||
<WebRTCFileUpload
|
||||
selectedFiles={selectedFiles}
|
||||
fileList={fileList}
|
||||
@@ -860,22 +672,21 @@ export const WebRTCFileTransfer: React.FC = () => {
|
||||
onClearFiles={clearFiles}
|
||||
onReset={resetRoom}
|
||||
disabled={!!currentTransferFile}
|
||||
isConnected={isConnected}
|
||||
isWebSocketConnected={isWebSocketConnected}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
|
||||
|
||||
<WebRTCFileReceive
|
||||
onJoinRoom={joinRoom}
|
||||
files={fileList}
|
||||
onDownloadFile={handleDownloadRequest}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
isWebSocketConnected={isWebSocketConnected}
|
||||
|
||||
downloadedFiles={downloadedFiles}
|
||||
error={error}
|
||||
onReset={resetConnection}
|
||||
pickupCode={pickupCode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
565
chuan-next/src/components/WebRTCSettings.tsx
Normal file
565
chuan-next/src/components/WebRTCSettings.tsx
Normal file
@@ -0,0 +1,565 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useWebRTCStore } from '@/hooks/connection/state/webConnectStore';
|
||||
import { IceServerConfig, useIceServersConfig } from '@/hooks/settings/useIceServersConfig';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Database,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
Wifi,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface AddServerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (config: Omit<IceServerConfig, 'id'>) => void;
|
||||
validateServer: (config: Omit<IceServerConfig, 'id'>) => string[];
|
||||
}
|
||||
|
||||
function AddServerModal({ isOpen, onClose, onSubmit, validateServer }: AddServerModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
urls: '',
|
||||
username: '',
|
||||
credential: '',
|
||||
type: 'stun' as 'stun' | 'turn',
|
||||
enabled: true,
|
||||
});
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateServer(formData);
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(formData);
|
||||
onClose();
|
||||
// 重置表单
|
||||
setFormData({
|
||||
urls: '',
|
||||
username: '',
|
||||
credential: '',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
});
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: 'stun' | 'turn') => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
type,
|
||||
username: type === 'stun' ? '' : prev.username,
|
||||
credential: type === 'stun' ? '' : prev.credential,
|
||||
}));
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setErrors([]);
|
||||
setFormData({
|
||||
urls: '',
|
||||
username: '',
|
||||
credential: '',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={(e) => {
|
||||
// 点击背景关闭弹窗
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto border border-gray-200 mx-4"
|
||||
onClick={(e) => e.stopPropagation()} // 防止点击弹窗内容时关闭
|
||||
>
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Plus className="w-5 h-5 text-blue-600" />
|
||||
<span className="hidden sm:inline">添加ICE服务器</span>
|
||||
<span className="sm:hidden">添加服务器</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 服务器类型 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
服务器类型
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="stun"
|
||||
checked={formData.type === 'stun'}
|
||||
onChange={(e) => handleTypeChange(e.target.value as 'stun' | 'turn')}
|
||||
className="text-blue-600"
|
||||
/>
|
||||
<span className="text-sm">STUN</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="turn"
|
||||
checked={formData.type === 'turn'}
|
||||
onChange={(e) => handleTypeChange(e.target.value as 'stun' | 'turn')}
|
||||
className="text-blue-600"
|
||||
/>
|
||||
<span className="text-sm">TURN</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 服务器地址 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
服务器地址 *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.urls}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, urls: e.target.value }))}
|
||||
placeholder={formData.type === 'stun' ? 'stun:your-server.com:3478' : 'turn:your-server.com:3478'}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formData.type === 'stun' ?
|
||||
'格式: stun:服务器地址:端口' :
|
||||
'格式: turn:服务器地址:端口'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TURN服务器认证信息 */}
|
||||
{formData.type === 'turn' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
用户名 *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="输入TURN服务器用户名"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码 *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.credential}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, credential: e.target.value }))}
|
||||
placeholder="输入TURN服务器密码"
|
||||
className="w-full pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{errors.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-800">配置错误</p>
|
||||
<ul className="text-sm text-red-700 mt-1 space-y-1">
|
||||
{errors.map((error, index) => (
|
||||
<li key={index}>• {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Button type="submit" className="flex-1 min-h-[44px] justify-center">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
添加服务器
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="sm:w-auto min-h-[44px] justify-center"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServerItemProps {
|
||||
server: IceServerConfig;
|
||||
onRemove: (id: string) => void;
|
||||
canRemove: boolean;
|
||||
}
|
||||
|
||||
function ServerItem({ server, onRemove, canRemove }: ServerItemProps) {
|
||||
return (
|
||||
<div className="border rounded-lg p-3 sm:p-4 bg-white">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
{server.type?.toUpperCase() || 'STUN'}
|
||||
</span>
|
||||
{server.isDefault && (
|
||||
<span className="text-xs bg-green-100 text-green-600 px-2 py-1 rounded whitespace-nowrap">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-700 break-all">
|
||||
{server.urls}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{server.username && (
|
||||
<p className="text-xs text-gray-500">
|
||||
用户名: {server.username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end sm:justify-start sm:ml-4">
|
||||
{canRemove && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onRemove(server.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 min-h-[44px] min-w-[44px] px-3 py-2 sm:min-h-[36px] sm:min-w-[36px]"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className="ml-2 sm:hidden">删除</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebRTCSettings() {
|
||||
const {
|
||||
iceServers,
|
||||
isLoading,
|
||||
addIceServer,
|
||||
removeIceServer,
|
||||
resetToDefault,
|
||||
validateServer,
|
||||
} = useIceServersConfig();
|
||||
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 获取WebRTC连接状态
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isPeerConnected,
|
||||
currentRoom
|
||||
} = useWebRTCStore();
|
||||
|
||||
// 检查是否有活跃连接
|
||||
const hasActiveConnection = isConnected || isConnecting || isPeerConnected;
|
||||
|
||||
const handleAddServer = (config: Omit<IceServerConfig, 'id'>) => {
|
||||
try {
|
||||
addIceServer(config);
|
||||
showToast('ICE服务器添加成功', 'success');
|
||||
} catch (error) {
|
||||
showToast('添加失败,请重试', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveServer = (id: string) => {
|
||||
setServerToDelete(id);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const confirmDeleteServer = () => {
|
||||
if (serverToDelete) {
|
||||
try {
|
||||
removeIceServer(serverToDelete);
|
||||
showToast('ICE服务器删除成功', 'success');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '删除失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setServerToDelete(null);
|
||||
setShowDeleteDialog(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDeleteServer = () => {
|
||||
setServerToDelete(null);
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
try {
|
||||
resetToDefault();
|
||||
showToast('已恢复默认配置', 'success');
|
||||
} catch (error) {
|
||||
showToast('恢复默认配置失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">加载设置中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6 px-4 sm:px-0">
|
||||
{/* 头部 */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="p-2 sm:p-3 bg-blue-100 rounded-xl">
|
||||
<Settings className="w-6 h-6 sm:w-8 sm:h-8 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">WebRTC 设置</h2>
|
||||
</div>
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
配置STUN/TURN服务器以优化网络连接性能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 隐私提示 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 sm:p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-900 mb-1 text-sm sm:text-base">隐私保护</h3>
|
||||
<p className="text-blue-800 text-xs sm:text-sm">
|
||||
所有配置数据仅存储在您的浏览器本地,不会同步到服务器
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接状态警告 */}
|
||||
{hasActiveConnection && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 sm:p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Wifi className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-900 mb-1 text-sm sm:text-base">连接状态提醒</h3>
|
||||
<div className="text-amber-800 text-xs sm:text-sm">
|
||||
<p>检测到当前有活跃的WebRTC连接</p>
|
||||
{currentRoom && (
|
||||
<p className="mt-1">
|
||||
房间: <span className="font-mono text-xs">{currentRoom.code}</span>
|
||||
({currentRoom.role === 'sender' ? '发送方' : '接收方'})
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs">
|
||||
修改ICE服务器配置不会影响现有连接,新配置将在下次建立连接时生效
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ICE服务器列表 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="p-3 sm:p-4 border-b border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900">ICE 服务器配置</h3>
|
||||
<p className="text-xs sm:text-sm text-gray-600">
|
||||
共 {iceServers.length} 个服务器配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetToDefault}
|
||||
className="flex items-center justify-center gap-2 min-h-[44px] px-4 py-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">恢复默认</span>
|
||||
<span className="sm:hidden">恢复</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center justify-center gap-2 min-h-[44px] px-4 py-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">添加服务器</span>
|
||||
<span className="sm:hidden">添加</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 sm:p-4">
|
||||
{/* 服务器列表 */}
|
||||
<div className="space-y-3">
|
||||
{iceServers.map((server) => (
|
||||
<ServerItem
|
||||
key={server.id}
|
||||
server={server}
|
||||
onRemove={handleRemoveServer}
|
||||
canRemove={iceServers.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 空状态 */}
|
||||
{iceServers.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<Database className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">暂无ICE服务器配置</p>
|
||||
<Button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="min-h-[44px] px-6"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加第一个服务器
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置说明 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2 text-sm sm:text-base">配置说明</h3>
|
||||
|
||||
<div className="space-y-3 text-xs sm:text-sm text-gray-700">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">STUN:</h4>
|
||||
<p className="text-gray-600">
|
||||
用于检测公网IP地址和端口,帮助建立P2P连接。
|
||||
</p>
|
||||
<p className="text-gray-600 mt-1">
|
||||
格式:<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">stun:服务器地址:端口</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">TURN:</h4>
|
||||
<p className="text-gray-600">
|
||||
当P2P连接失败时,通过中继服务器转发数据,需要用户名和密码认证。
|
||||
</p>
|
||||
<p className="text-gray-600 mt-1">
|
||||
格式:<code className="bg-gray-100 px-1 py-0.5 rounded text-xs">turn:服务器地址:端口</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-1">默认服务器:</h4>
|
||||
<p className="text-gray-600">
|
||||
系统预置的可靠ICE服务器,建议保留以确保连接稳定性。可以根据需要删除或添加自定义服务器。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加服务器弹窗 */}
|
||||
<AddServerModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSubmit={handleAddServer}
|
||||
validateServer={validateServer}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteDialog}
|
||||
onClose={cancelDeleteServer}
|
||||
onConfirm={confirmDeleteServer}
|
||||
title="删除ICE服务器"
|
||||
message={(() => {
|
||||
if (!serverToDelete) return "确定要删除这个ICE服务器吗?";
|
||||
|
||||
const serverToDeleteInfo = iceServers.find(s => s.id === serverToDelete);
|
||||
|
||||
if (iceServers.length <= 1) {
|
||||
return "这是最后一个ICE服务器,删除后将无法建立WebRTC连接。确定要删除吗?";
|
||||
} else if (serverToDeleteInfo?.isDefault) {
|
||||
return "这是一个默认ICE服务器,删除后可能需要手动添加其他服务器。确定要删除吗?";
|
||||
} else {
|
||||
return "确定要删除这个ICE服务器吗?删除后将无法恢复。";
|
||||
}
|
||||
})()}
|
||||
confirmText="删除"
|
||||
cancelText="取消"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Send, Download, X } from 'lucide-react';
|
||||
import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender';
|
||||
import { WebRTCTextReceiver } from '@/components/webrtc/WebRTCTextReceiver';
|
||||
import { 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 = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// 状态管理
|
||||
const [mode, setMode] = useState<'send' | 'receive'>('send');
|
||||
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
|
||||
// 使用全局WebRTC状态
|
||||
const webrtcState = useWebRTCStore();
|
||||
|
||||
// 从URL参数中获取初始模式
|
||||
useEffect(() => {
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type');
|
||||
const code = searchParams.get('code');
|
||||
|
||||
if (!hasProcessedInitialUrl && type === 'message' && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
console.log('=== 处理初始URL参数 ===');
|
||||
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
|
||||
|
||||
setMode(urlMode);
|
||||
setHasProcessedInitialUrl(true);
|
||||
}
|
||||
}, [searchParams, hasProcessedInitialUrl]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = (newMode: 'send' | 'receive') => {
|
||||
console.log('=== 切换模式 ===', newMode);
|
||||
|
||||
setMode(newMode);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'message');
|
||||
params.set('mode', newMode);
|
||||
|
||||
if (newMode === 'send') {
|
||||
params.delete('code');
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
// 使用统一的URL处理器
|
||||
const { updateMode, getCurrentRoomCode, clearURLParams } = useURLHandler({
|
||||
featureType: 'message',
|
||||
onModeChange: setMode
|
||||
});
|
||||
|
||||
// 重新开始函数
|
||||
const handleRestart = () => {
|
||||
const handleRestart = useCallback(() => {
|
||||
setPreviewImage(null);
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', 'message');
|
||||
params.set('mode', mode);
|
||||
params.delete('code');
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
clearURLParams();
|
||||
}, [clearURLParams]);
|
||||
|
||||
const code = searchParams.get('code') || '';
|
||||
const code = getCurrentRoomCode();
|
||||
|
||||
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
|
||||
const handleConnectionChange = useCallback((connection: any) => {
|
||||
// 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它
|
||||
console.log('连接状态变化:', connection);
|
||||
}, []);
|
||||
|
||||
// 关闭图片预览
|
||||
const closePreview = useCallback(() => {
|
||||
setPreviewImage(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
@@ -85,24 +66,31 @@ export const WebRTCTextImageTransfer: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
|
||||
|
||||
|
||||
{mode === 'send' ? (
|
||||
<WebRTCTextSender onRestart={handleRestart} onPreviewImage={setPreviewImage} />
|
||||
<WebRTCTextSender
|
||||
onRestart={handleRestart}
|
||||
onPreviewImage={setPreviewImage}
|
||||
onConnectionChange={handleConnectionChange}
|
||||
/>
|
||||
) : (
|
||||
<WebRTCTextReceiver
|
||||
initialCode={code}
|
||||
onPreviewImage={setPreviewImage}
|
||||
onRestart={handleRestart}
|
||||
onConnectionChange={handleConnectionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 图片预览模态框 */}
|
||||
{previewImage && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={() => setPreviewImage(null)}>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={closePreview}>
|
||||
<div className="relative max-w-4xl max-h-4xl">
|
||||
<img src={previewImage} alt="预览" className="max-w-full max-h-full" />
|
||||
<Button
|
||||
onClick={() => setPreviewImage(null)}
|
||||
onClick={closePreview}
|
||||
className="absolute top-4 right-4 bg-white text-black hover:bg-gray-200"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
215
chuan-next/src/components/WebRTCUnsupportedModal.tsx
Normal file
215
chuan-next/src/components/WebRTCUnsupportedModal.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { WebRTCSupport, getBrowserInfo, getRecommendedBrowsers } from '@/lib/webrtc-support';
|
||||
import { AlertTriangle, Chrome, Download, Monitor, X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
webrtcSupport: WebRTCSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC 不支持提示模态框
|
||||
*/
|
||||
export function WebRTCUnsupportedModal({ isOpen, onClose, webrtcSupport }: Props) {
|
||||
const browserInfo = getBrowserInfo();
|
||||
const recommendedBrowsers = getRecommendedBrowsers();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBrowserDownload = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<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 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">
|
||||
<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 hover:bg-gray-100 rounded-lg p-1 transition-all duration-200"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 当前浏览器信息 */}
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 缺失的功能 */}
|
||||
<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-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-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>
|
||||
<div className="font-medium text-blue-800">屏幕共享</div>
|
||||
<div className="text-blue-600">实时共享您的桌面屏幕</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="font-medium text-blue-800">文件传输</div>
|
||||
<div className="text-blue-600">点对点直接传输文件,快速且安全</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="font-medium text-blue-800">文本传输</div>
|
||||
<div className="text-blue-600">实时文本和图像传输</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 浏览器推荐 */}
|
||||
<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="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-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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 浏览器特定建议 */}
|
||||
{browserInfo.recommendations && (
|
||||
<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-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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 技术详情(可折叠) */}
|
||||
<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-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 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>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<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-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-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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
106
chuan-next/src/components/ui/confirm-dialog.tsx
Normal file
106
chuan-next/src/components/ui/confirm-dialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, Wifi, WifiOff } from 'lucide-react';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
type?: 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
type = 'warning'
|
||||
}: ConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return <WifiOff className="w-6 h-6 text-red-500" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="w-6 h-6 text-yellow-500" />;
|
||||
case 'info':
|
||||
return <Wifi className="w-6 h-6 text-blue-500" />;
|
||||
default:
|
||||
return <AlertTriangle className="w-6 h-6 text-yellow-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonStyles = () => {
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return 'bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700';
|
||||
case 'warning':
|
||||
return 'bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600';
|
||||
case 'info':
|
||||
return 'bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600';
|
||||
default:
|
||||
return 'bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600';
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl border border-white/20 max-w-md w-full mx-4 animate-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center space-x-4 p-6 pb-4">
|
||||
<div className="flex-shrink-0">
|
||||
{getIcon()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6">
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end space-x-3 px-6 pb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
className="px-6 py-2 border-slate-200 text-slate-600 hover:text-slate-800 hover:border-slate-300 rounded-lg"
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className={`px-6 py-2 text-white font-medium rounded-lg shadow-lg transition-all duration-200 hover:shadow-xl ${getButtonStyles()}`}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -1,24 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
513
chuan-next/src/components/webrtc/WebRTCDesktopReceiver.tsx
Normal file
513
chuan-next/src/components/webrtc/WebRTCDesktopReceiver.tsx
Normal file
@@ -0,0 +1,513 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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';
|
||||
|
||||
interface WebRTCDesktopReceiverProps {
|
||||
className?: string;
|
||||
initialCode?: string; // 支持从URL参数传入的房间代码
|
||||
onConnectionChange?: (connection: any) => void;
|
||||
}
|
||||
|
||||
export default function WebRTCDesktopReceiver({ className, initialCode, onConnectionChange }: WebRTCDesktopReceiverProps) {
|
||||
const [inputCode, setInputCode] = useState(initialCode || '');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isJoiningRoom, setIsJoiningRoom] = useState(false); // 添加加入房间状态
|
||||
const hasTriedAutoJoin = React.useRef(false); // 添加 ref 来跟踪是否已尝试自动加入
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 使用桌面共享业务逻辑
|
||||
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(() => {
|
||||
if (onConnectionChange && desktopShare.webRTCConnection) {
|
||||
onConnectionChange(desktopShare.webRTCConnection);
|
||||
}
|
||||
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
|
||||
|
||||
// 加入观看
|
||||
const handleJoinViewing = useCallback(async () => {
|
||||
const trimmedCode = inputCode.trim();
|
||||
|
||||
// 检查房间代码格式
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位房间代码', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复调用 - 检查是否已经在连接或已连接
|
||||
if (desktopShare.isConnecting || desktopShare.isViewing || isJoiningRoom) {
|
||||
console.log('已在连接中或已连接,跳过重复的房间状态检查');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJoiningRoom(true);
|
||||
|
||||
try {
|
||||
console.log('[DesktopShareReceiver] 开始验证房间状态...');
|
||||
|
||||
// 先检查房间状态
|
||||
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
let errorMessage = result.message || '房间不存在或已过期';
|
||||
if (result.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (result.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查房间代码是否正确';
|
||||
}
|
||||
showToast(errorMessage, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
if (result.is_room_full) {
|
||||
showToast('当前房间人数已满,正在传输中无法加入,请稍后再试', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查发送方是否在线
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DesktopShareReceiver] 房间状态检查通过,开始连接...');
|
||||
setIsLoading(true);
|
||||
|
||||
await desktopShare.joinSharing(trimmedCode.toUpperCase());
|
||||
console.log('[DesktopShareReceiver] 加入观看成功');
|
||||
|
||||
showToast('已加入桌面共享', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareReceiver] 加入观看失败:', error);
|
||||
|
||||
let errorMessage = '加入观看失败';
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
errorMessage = '网络连接失败,请检查网络状况';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = '请求超时,请重试';
|
||||
} else if (error.message.includes('HTTP 404')) {
|
||||
errorMessage = '房间不存在,请检查房间代码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
errorMessage = '服务器错误,请稍后重试';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsJoiningRoom(false); // 重置加入房间状态
|
||||
}
|
||||
}, [desktopShare, inputCode, isJoiningRoom, showToast]);
|
||||
|
||||
// 停止观看桌面
|
||||
const handleStopViewing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await desktopShare.stopViewing();
|
||||
showToast('已退出桌面共享', 'success');
|
||||
setInputCode('');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareReceiver] 停止观看失败:', error);
|
||||
showToast('退出失败', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 开启语音
|
||||
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 触发, 参数:', {
|
||||
initialCode,
|
||||
isViewing: desktopShare.isViewing,
|
||||
isConnecting: desktopShare.isConnecting,
|
||||
isJoiningRoom,
|
||||
hasTriedAutoJoin: hasTriedAutoJoin.current
|
||||
});
|
||||
|
||||
const autoJoin = async () => {
|
||||
if (initialCode && !desktopShare.isViewing && !desktopShare.isConnecting && !isJoiningRoom && !hasTriedAutoJoin.current) {
|
||||
hasTriedAutoJoin.current = true;
|
||||
const trimmedCode = initialCode.trim();
|
||||
|
||||
// 检查房间代码格式
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('房间代码格式不正确', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJoiningRoom(true);
|
||||
console.log('[WebRTCDesktopReceiver] 检测到初始代码,开始验证并自动加入:', trimmedCode);
|
||||
|
||||
try {
|
||||
// 先检查房间状态
|
||||
console.log('[WebRTCDesktopReceiver] 验证房间状态...');
|
||||
const response = await fetch(`/api/room-info?code=${trimmedCode}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
let errorMessage = result.message || '房间不存在或已过期';
|
||||
if (result.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (result.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查房间代码是否正确';
|
||||
}
|
||||
showToast(errorMessage, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
if (result.is_room_full) {
|
||||
showToast('当前房间人数已满,正在传输中无法加入,请稍后再试', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查发送方是否在线
|
||||
if (!result.sender_online) {
|
||||
showToast('发送方不在线,请确认房间代码是否正确或联系发送方', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WebRTCDesktopReceiver] 房间验证通过,开始自动连接...');
|
||||
setIsLoading(true);
|
||||
|
||||
await desktopShare.joinSharing(trimmedCode.toUpperCase());
|
||||
console.log('[WebRTCDesktopReceiver] 自动加入观看成功');
|
||||
showToast('已加入桌面共享', 'success');
|
||||
} catch (error) {
|
||||
console.error('[WebRTCDesktopReceiver] 自动加入观看失败:', error);
|
||||
|
||||
let errorMessage = '自动加入观看失败';
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
errorMessage = '网络连接失败,请检查网络状况';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = '请求超时,请重试';
|
||||
} else if (error.message.includes('HTTP 404')) {
|
||||
errorMessage = '房间不存在,请检查房间代码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
errorMessage = '服务器错误,请稍后重试';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsJoiningRoom(false);
|
||||
}
|
||||
} else {
|
||||
console.log('[WebRTCDesktopReceiver] 不满足自动加入条件:', {
|
||||
hasInitialCode: !!initialCode,
|
||||
notViewing: !desktopShare.isViewing,
|
||||
notConnecting: !desktopShare.isConnecting,
|
||||
notJoiningRoom: !isJoiningRoom,
|
||||
notTriedBefore: !hasTriedAutoJoin.current
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
autoJoin();
|
||||
}, [initialCode, desktopShare.isViewing, desktopShare.isConnecting, isJoiningRoom]); // 添加isJoiningRoom依赖
|
||||
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 sm:space-y-6 ${className || ''}`}>
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
|
||||
<div className="space-y-6">
|
||||
{!desktopShare.isViewing ? (
|
||||
// 输入房间代码界面 - 与文本消息风格一致
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">输入房间代码</h2>
|
||||
<p className="text-sm text-slate-600">请输入6位房间代码来观看桌面共享</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionStatus
|
||||
currentRoom={desktopShare.connectionCode ? { code: desktopShare.connectionCode, role: 'receiver' } : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleJoinViewing(); }} className="space-y-4 sm:space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inputCode}
|
||||
onChange={(e) => setInputCode(e.target.value.replace(/[^123456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnpqrstuvwxyz]/g, ''))}
|
||||
placeholder="请输入房间代码"
|
||||
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-purple-500 focus:ring-purple-500 bg-white/80 backdrop-blur-sm pb-2 sm:pb-4"
|
||||
maxLength={6}
|
||||
disabled={isLoading || isJoiningRoom}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-xs sm:text-sm text-slate-500">
|
||||
{inputCode.length}/6 位
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={inputCode.length !== 6 || isLoading || isJoiningRoom}
|
||||
className="w-full h-10 sm:h-12 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white text-base sm:text-lg font-medium rounded-xl shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
|
||||
>
|
||||
{isJoiningRoom ? (
|
||||
<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>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>连接中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Monitor className="w-5 h-5" />
|
||||
<span>加入观看</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
// 已连接,显示桌面观看界面
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">桌面观看</h3>
|
||||
<p className="text-sm text-slate-600">房间代码: {inputCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 连接状态 */}
|
||||
<ConnectionStatus
|
||||
currentRoom={{ code: inputCode, role: 'receiver' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 观看中的控制面板 - 移动端优化 */}
|
||||
<div className="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>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 语音状态指示器 - 始终显示,点击切换 */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
504
chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx
Normal file
504
chuan-next/src/components/webrtc/WebRTCDesktopSender.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
import { ConnectionStatus } from '@/components/ConnectionStatus';
|
||||
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { Monitor, Repeat, Share, Square, Mic, MicOff } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface WebRTCDesktopSenderProps {
|
||||
className?: string;
|
||||
onConnectionChange?: (connection: any) => void;
|
||||
}
|
||||
|
||||
export default function WebRTCDesktopSender({ className, onConnectionChange }: WebRTCDesktopSenderProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 使用桌面共享业务逻辑
|
||||
const desktopShare = useDesktopShareBusiness();
|
||||
|
||||
// 使用语音通话业务逻辑 - 传入同一个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(() => {
|
||||
if (onConnectionChange && desktopShare.webRTCConnection) {
|
||||
onConnectionChange(desktopShare.webRTCConnection);
|
||||
}
|
||||
}, [onConnectionChange, desktopShare.isWebSocketConnected, desktopShare.isPeerConnected, desktopShare.isConnecting]);
|
||||
|
||||
// 监听连接状态变化,当P2P连接断开时保持桌面共享状态
|
||||
const prevPeerConnectedRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 只有从连接状态变为断开状态时才处理
|
||||
const wasPreviouslyConnected = prevPeerConnectedRef.current;
|
||||
const isCurrentlyConnected = desktopShare.isPeerConnected;
|
||||
|
||||
// 更新ref
|
||||
prevPeerConnectedRef.current = isCurrentlyConnected;
|
||||
|
||||
// 如果正在共享且从连接变为断开,保持桌面共享状态以便新用户加入
|
||||
if (desktopShare.isSharing &&
|
||||
wasPreviouslyConnected &&
|
||||
!isCurrentlyConnected &&
|
||||
desktopShare.connectionCode) {
|
||||
|
||||
console.log('[DesktopShareSender] 检测到P2P连接断开,保持桌面共享状态等待新用户');
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await desktopShare.handlePeerDisconnect();
|
||||
console.log('[DesktopShareSender] 已处理P2P断开,保持桌面共享状态');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 处理P2P断开失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
handleDisconnect();
|
||||
}
|
||||
}, [desktopShare.isSharing, desktopShare.isPeerConnected, desktopShare.connectionCode]); // 移除handlePeerDisconnect依赖
|
||||
|
||||
// 复制房间代码
|
||||
const copyCode = useCallback(async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
showToast('房间代码已复制到剪贴板', 'success');
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
showToast('复制失败,请手动复制', 'error');
|
||||
}
|
||||
}, [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);
|
||||
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 开始桌面共享
|
||||
const handleStartSharing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShareSender] 用户点击开始桌面共享');
|
||||
|
||||
await desktopShare.startSharing();
|
||||
console.log('[DesktopShareSender] 桌面共享开始成功');
|
||||
|
||||
showToast('桌面共享已开始', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 开始桌面共享失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
|
||||
showToast(errorMessage, 'error');
|
||||
|
||||
// 分享失败时重置状态,让用户重新选择桌面
|
||||
try {
|
||||
// await desktopShare.resetSharing();
|
||||
console.log('[DesktopShareSender] 已重置共享状态,用户可以重新选择桌面');
|
||||
} catch (resetError) {
|
||||
console.error('[DesktopShareSender] 重置共享状态失败:', resetError);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 切换桌面
|
||||
const handleSwitchDesktop = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShareSender] 用户点击切换桌面');
|
||||
|
||||
await desktopShare.switchDesktop();
|
||||
console.log('[DesktopShareSender] 桌面切换成功');
|
||||
|
||||
showToast('桌面切换成功', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 切换桌面失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
|
||||
showToast(errorMessage, 'error');
|
||||
|
||||
// 切换桌面失败时重置状态,让用户重新选择桌面
|
||||
try {
|
||||
await desktopShare.resetSharing();
|
||||
console.log('[DesktopShareSender] 已重置共享状态,用户可以重新选择桌面');
|
||||
} catch (resetError) {
|
||||
console.error('[DesktopShareSender] 重置共享状态失败:', resetError);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 停止桌面共享
|
||||
const handleStopSharing = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log('[DesktopShareSender] 用户点击停止桌面共享');
|
||||
|
||||
await desktopShare.stopSharing();
|
||||
console.log('[DesktopShareSender] 桌面共享停止成功');
|
||||
|
||||
showToast('桌面共享已停止', 'success');
|
||||
} catch (error) {
|
||||
console.error('[DesktopShareSender] 停止桌面共享失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
|
||||
showToast(errorMessage, 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [desktopShare, showToast]);
|
||||
|
||||
// 开启语音
|
||||
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">
|
||||
{!desktopShare.connectionCode ? (
|
||||
// 创建房间前的界面
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">共享桌面</h2>
|
||||
<p className="text-sm text-slate-600">分享您的屏幕给其他人</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionStatus
|
||||
currentRoom={desktopShare.connectionCode ? { code: desktopShare.connectionCode, role: 'sender' } : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-full flex items-center justify-center">
|
||||
<Monitor className="w-10 h-10 text-purple-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">创建桌面共享房间</h3>
|
||||
<p className="text-slate-600 mb-8">创建房间后将生成分享码,等待接收方加入后即可开始桌面共享</p>
|
||||
|
||||
<Button
|
||||
onClick={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"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share className="w-5 h-5 mr-2" />
|
||||
开始桌面共享
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 房间已创建,显示取件码和等待界面
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Monitor className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">共享桌面</h2>
|
||||
<p className="text-sm text-slate-600">房间代码: {desktopShare.connectionCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionStatus
|
||||
currentRoom={{ code: desktopShare.connectionCode, role: 'sender' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 桌面共享控制区域 */}
|
||||
{desktopShare.canStartSharing && (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-slate-800 flex items-center">
|
||||
<Monitor className="w-5 h-5 mr-2" />
|
||||
桌面共享控制
|
||||
</h4>
|
||||
{/* 控制按钮 */}
|
||||
{desktopShare.isSharing && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={handleSwitchDesktop}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-slate-700 border-slate-300"
|
||||
>
|
||||
<Repeat className="w-4 h-4 mr-1" />
|
||||
切换桌面
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStopSharing}
|
||||
disabled={isLoading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
停止共享
|
||||
</Button>
|
||||
|
||||
{/* 语音控制按钮 */}
|
||||
<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 ? "点击关闭发言" : "点击开启发言"}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 房间信息显示 */}
|
||||
<RoomInfoDisplay
|
||||
code={desktopShare.connectionCode}
|
||||
link={`${typeof window !== 'undefined' ? window.location.origin : ''}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`}
|
||||
icon={Monitor}
|
||||
iconColor="from-emerald-500 to-teal-500"
|
||||
codeColor="from-purple-600 to-indigo-600"
|
||||
title="房间码生成成功!"
|
||||
subtitle="分享以下信息给观看方"
|
||||
codeLabel="房间代码"
|
||||
qrLabel="扫码观看"
|
||||
copyButtonText="复制房间代码"
|
||||
copyButtonColor="bg-purple-500 hover:bg-purple-600"
|
||||
qrButtonText="使用手机扫码快速观看"
|
||||
linkButtonText="复制链接"
|
||||
onCopyCode={() => copyCode(desktopShare.connectionCode)}
|
||||
onCopyLink={() => {
|
||||
const link = `${window.location.origin}?type=desktop&mode=receive&code=${desktopShare.connectionCode}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
showToast('观看链接已复制', 'success');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 隐藏的远程音频播放元素 - 用于播放观看方的语音 */}
|
||||
<audio
|
||||
ref={setRemoteAudioRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +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 { 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;
|
||||
@@ -13,6 +16,8 @@ interface FileInfo {
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
transferSpeed?: number; // bytes per second
|
||||
startTime?: number; // 传输开始时间
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
@@ -35,28 +40,34 @@ 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;
|
||||
pickupCode?: string;
|
||||
}
|
||||
|
||||
export function WebRTCFileReceive({
|
||||
onJoinRoom,
|
||||
files,
|
||||
onDownloadFile,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isWebSocketConnected = false,
|
||||
downloadedFiles,
|
||||
error = null,
|
||||
onReset
|
||||
onReset,
|
||||
pickupCode: propPickupCode
|
||||
}: WebRTCFileReceiveProps) {
|
||||
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> => {
|
||||
@@ -70,7 +81,16 @@ export function WebRTCFileReceive({
|
||||
console.log('验证响应:', { status: response.status, data });
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
const errorMessage = data.message || '取件码验证失败';
|
||||
let errorMessage = data.message || '取件码验证失败';
|
||||
|
||||
// 特殊处理房间人数已满的情况
|
||||
if (data.message?.includes('房间人数已满') || data.message?.includes('正在传输中无法加入')) {
|
||||
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
|
||||
} else if (data.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (data.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
|
||||
// 显示toast错误提示
|
||||
showToast(errorMessage, 'error');
|
||||
@@ -79,6 +99,14 @@ export function WebRTCFileReceive({
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
if (data.is_room_full) {
|
||||
const errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
|
||||
showToast(errorMessage, 'error');
|
||||
console.log('房间已满:', errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('取件码验证成功:', data.room);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -109,7 +137,7 @@ export function WebRTCFileReceive({
|
||||
}, [pickupCode, onJoinRoom]);
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase();
|
||||
const value = e.target.value.replace(/[^123456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnpqrstuvwxyz]/g, '');
|
||||
if (value.length <= 6) {
|
||||
setPickupCode(value);
|
||||
}
|
||||
@@ -117,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('重置取件码输入');
|
||||
@@ -126,76 +154,45 @@ 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>
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">等待文件</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{isConnected ? '已连接到房间,等待发送方选择文件...' : '正在连接到房间...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
{getConnectState().isWebSocketConnected}
|
||||
<h3 className="text-lg font-semibold text-slate-800">文件接收中</h3>
|
||||
<p className="text-sm text-slate-600">取件码: {displayPickupCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<ConnectionStatus
|
||||
currentRoom={displayPickupCode ? { code: displayPickupCode, role: 'receiver' } : null}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={onReset}
|
||||
variant="outline"
|
||||
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
|
||||
>
|
||||
重新开始
|
||||
</Button>
|
||||
</div>
|
||||
</div> <div className="text-center">
|
||||
{/* 连接状态指示器 */}
|
||||
<div className="flex items-center justify-center space-x-4 mb-6">
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
@@ -226,67 +223,22 @@ export function WebRTCFileReceive({
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">可下载文件</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
{isConnected ? (
|
||||
<span className="text-emerald-600">✅ 已连接,可以下载文件</span>
|
||||
) : (
|
||||
<span className="text-amber-600">⏳ 正在建立连接...</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">房间代码: {displayPickupCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
{files.length} 个文件
|
||||
</div>
|
||||
</div>
|
||||
{/* 连接状态 */}
|
||||
<ConnectionStatus
|
||||
|
||||
currentRoom={{ code: displayPickupCode, role: 'receiver' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -296,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">
|
||||
@@ -320,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'
|
||||
@@ -343,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">
|
||||
@@ -371,8 +373,8 @@ export function WebRTCFileReceive({
|
||||
return (
|
||||
<div>
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex items-center justify-between mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
@@ -382,57 +384,10 @@ export function WebRTCFileReceive({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">WS</span>
|
||||
</>
|
||||
) : isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : isConnecting ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 连接状态 */}
|
||||
<ConnectionStatus
|
||||
currentRoom={null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
|
||||
@@ -444,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) => (
|
||||
@@ -468,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,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { Upload, FileText, Image, Video, Music, Archive, X } from 'lucide-react';
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay';
|
||||
import { 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 {
|
||||
id: string;
|
||||
@@ -13,6 +15,8 @@ interface FileInfo {
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
transferSpeed?: number; // bytes per second
|
||||
startTime?: number; // 传输开始时间
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
@@ -45,8 +49,6 @@ interface WebRTCFileUploadProps {
|
||||
onClearFiles?: () => void;
|
||||
onReset?: () => void;
|
||||
disabled?: boolean;
|
||||
isConnected?: boolean;
|
||||
isWebSocketConnected?: boolean;
|
||||
}
|
||||
|
||||
export function WebRTCFileUpload({
|
||||
@@ -62,12 +64,13 @@ export function WebRTCFileUpload({
|
||||
onRemoveFile,
|
||||
onClearFiles,
|
||||
onReset,
|
||||
disabled = false,
|
||||
isConnected = false,
|
||||
isWebSocketConnected = false
|
||||
disabled = false
|
||||
}: WebRTCFileUploadProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 用于跟踪传输进度的trackers
|
||||
const transferTrackers = useRef<Map<string, TransferProgressTracker>>(new Map());
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -115,9 +118,9 @@ export function WebRTCFileUpload({
|
||||
if (selectedFiles.length === 0 && !pickupCode) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
{/* 功能标题 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<Upload className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
@@ -127,29 +130,9 @@ export function WebRTCFileUpload({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionStatus
|
||||
currentRoom={null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -197,63 +180,22 @@ export function WebRTCFileUpload({
|
||||
{/* 文件列表 */}
|
||||
<div>
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-4 sm:mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* 标题部分 */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">已选择文件</h3>
|
||||
<p className="text-sm text-slate-500">{selectedFiles.length} 个文件准备传输</p>
|
||||
<p className="text-sm text-slate-600">{selectedFiles.length} 个文件准备传输</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isWebSocketConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">WS</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">WS</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span className="text-emerald-600">RTC</span>
|
||||
</>
|
||||
) : pickupCode ? (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||
<span className="text-orange-600">RTC</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-400"></div>
|
||||
<span className="text-slate-600">RTC</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 使用 ConnectionStatus 组件 */}
|
||||
<ConnectionStatus
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-4 sm:mb-6">
|
||||
@@ -261,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}`}
|
||||
@@ -291,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
|
||||
@@ -309,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">
|
||||
@@ -397,80 +390,24 @@ export function WebRTCFileUpload({
|
||||
</div>
|
||||
|
||||
{/* 取件码展示 */}
|
||||
{pickupCode && (
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
{/* 左上角状态提示 - 类似已选择文件的风格 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">取件码生成成功!</h3>
|
||||
<p className="text-sm text-slate-600">分享以下信息给接收方</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
|
||||
{/* 左侧:取件码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">取件码</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
|
||||
{pickupCode}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCopyCode}
|
||||
className="w-full px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
|
||||
>
|
||||
复制取件码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
|
||||
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
|
||||
|
||||
{/* 右侧:二维码 */}
|
||||
{pickupLink && (
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">扫码传输</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<QRCodeDisplay
|
||||
value={pickupLink}
|
||||
size={120}
|
||||
title=""
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
|
||||
使用手机扫码快速访问
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:取件链接 */}
|
||||
{pickupLink && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
|
||||
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
|
||||
{pickupLink}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCopyLink}
|
||||
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
|
||||
>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{pickupCode && pickupLink && (
|
||||
<RoomInfoDisplay
|
||||
code={pickupCode}
|
||||
link={pickupLink}
|
||||
icon={FileText}
|
||||
iconColor="from-emerald-500 to-teal-500"
|
||||
codeColor="from-emerald-600 to-teal-600"
|
||||
title="取件码生成成功!"
|
||||
subtitle="分享以下信息给接收方"
|
||||
codeLabel="取件码"
|
||||
qrLabel="扫码传输"
|
||||
copyButtonText="复制取件码"
|
||||
copyButtonColor="bg-emerald-500 hover:bg-emerald-600"
|
||||
qrButtonText="使用手机扫码快速访问"
|
||||
linkButtonText="复制链接"
|
||||
onCopyCode={onCopyCode || (() => {})}
|
||||
onCopyLink={onCopyLink || (() => {})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useSharedWebRTCManager } from '@/hooks/webrtc/useSharedWebRTCManager';
|
||||
import { useTextTransferBusiness } from '@/hooks/webrtc/useTextTransferBusiness';
|
||||
import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness';
|
||||
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 { 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;
|
||||
onPreviewImage: (imageUrl: string) => void;
|
||||
onRestart?: () => void;
|
||||
onConnectionChange?: (connection: any) => void;
|
||||
}
|
||||
|
||||
export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
initialCode = '',
|
||||
onPreviewImage,
|
||||
onRestart
|
||||
onRestart,
|
||||
onConnectionChange
|
||||
}) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
@@ -29,12 +32,15 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
const [receivedImages, setReceivedImages] = useState<Array<{ id: string, content: string, fileName?: string }>>([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
// Ref用于防止重复自动连接
|
||||
const hasTriedAutoConnect = useRef(false);
|
||||
|
||||
// 创建共享连接
|
||||
const connection = useConnectManager();
|
||||
|
||||
// 创建共享连接 [需要优化]
|
||||
const connection = useSharedWebRTCManager();
|
||||
|
||||
const {getConnectState} = connection;
|
||||
|
||||
// 使用共享连接创建业务层
|
||||
const textTransfer = useTextTransferBusiness(connection);
|
||||
const fileTransfer = useFileTransferBusiness(connection);
|
||||
@@ -42,116 +48,49 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
// 连接所有传输通道
|
||||
const connectAll = useCallback(async (code: string, role: 'sender' | 'receiver') => {
|
||||
console.log('=== 连接所有传输通道 ===', { code, role });
|
||||
// 只需要连接一次,因为使用的是共享连接
|
||||
await connection.connect(code, role);
|
||||
// await Promise.all([
|
||||
// textTransfer.connect(code, role),
|
||||
// fileTransfer.connect(code, role)
|
||||
// ]);
|
||||
}, [textTransfer, fileTransfer]);
|
||||
}, [connection]);
|
||||
|
||||
// 是否有任何连接
|
||||
const hasAnyConnection = textTransfer.isConnected || fileTransfer.isConnected;
|
||||
|
||||
|
||||
// 是否正在连接
|
||||
const isAnyConnecting = textTransfer.isConnecting || fileTransfer.isConnecting;
|
||||
|
||||
// 通知父组件连接状态变化
|
||||
useEffect(() => {
|
||||
if (onConnectionChange) {
|
||||
onConnectionChange(connection);
|
||||
}
|
||||
}, [onConnectionChange, getConnectState().isConnected, getConnectState().isConnecting, getConnectState().isPeerConnected]);
|
||||
|
||||
// 是否有任何错误
|
||||
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
|
||||
|
||||
// 监听连接错误并显示 toast
|
||||
useEffect(() => {
|
||||
if (hasAnyError) {
|
||||
console.error('[WebRTCTextReceiver] 连接错误:', hasAnyError);
|
||||
showToast(hasAnyError, 'error');
|
||||
}
|
||||
}, [hasAnyError, showToast]);
|
||||
|
||||
// 验证取件码是否存在
|
||||
const validatePickupCode = async (code: string): Promise<boolean> => {
|
||||
try {
|
||||
setIsValidating(true);
|
||||
|
||||
console.log('开始验证取件码:', code);
|
||||
const response = await fetch(`/api/room-info?code=${code}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('验证响应:', { status: response.status, data });
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
const errorMessage = data.message || '取件码验证失败';
|
||||
showToast(errorMessage, 'error');
|
||||
console.log('验证失败:', errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('取件码验证成功:', data.room);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('验证取件码时发生错误:', error);
|
||||
const errorMessage = '网络错误,请检查连接后重试';
|
||||
showToast(errorMessage, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重新开始
|
||||
const restart = () => {
|
||||
setPickupCode('');
|
||||
setInputCode('');
|
||||
setReceivedText('');
|
||||
setReceivedImages([]);
|
||||
setIsTyping(false);
|
||||
|
||||
// 断开连接
|
||||
|
||||
// 清理接收的图片URL
|
||||
receivedImages.forEach(img => {
|
||||
if (img.content.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(img.content);
|
||||
}
|
||||
});
|
||||
setReceivedImages([]);
|
||||
|
||||
// 断开连接(只需要断开一次)
|
||||
connection.disconnect();
|
||||
|
||||
|
||||
if (onRestart) {
|
||||
onRestart();
|
||||
}
|
||||
};
|
||||
|
||||
// 加入房间
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
const trimmedCode = code.trim().toUpperCase();
|
||||
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
showToast('请输入正确的6位取件码', "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAnyConnecting || isValidating) {
|
||||
console.log('已经在连接中,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAnyConnection) {
|
||||
console.log('已经连接,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('=== 开始验证和连接房间 ===', trimmedCode);
|
||||
|
||||
const isValid = await validatePickupCode(trimmedCode);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPickupCode(trimmedCode);
|
||||
await connectAll(trimmedCode, 'receiver');
|
||||
|
||||
console.log('=== 房间连接成功 ===', trimmedCode);
|
||||
showToast(`成功加入消息房间: ${trimmedCode}`, "success");
|
||||
} catch (error) {
|
||||
console.error('加入房间失败:', error);
|
||||
showToast(error instanceof Error ? error.message : '加入房间失败', "error");
|
||||
setPickupCode('');
|
||||
}
|
||||
}, [isAnyConnecting, hasAnyConnection, connectAll, showToast, isValidating, validatePickupCode]);
|
||||
|
||||
// 监听实时文本同步
|
||||
useEffect(() => {
|
||||
const cleanup = textTransfer.onTextSync((text: string) => {
|
||||
@@ -174,25 +113,82 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
useEffect(() => {
|
||||
const cleanup = fileTransfer.onFileReceived((fileData) => {
|
||||
if (fileData.file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const imageData = e.target?.result as string;
|
||||
setReceivedImages(prev => [...prev, {
|
||||
id: fileData.id,
|
||||
content: imageData,
|
||||
fileName: fileData.file.name
|
||||
}]);
|
||||
};
|
||||
reader.readAsDataURL(fileData.file);
|
||||
const imageUrl = URL.createObjectURL(fileData.file);
|
||||
const imageId = Date.now().toString();
|
||||
|
||||
setReceivedImages(prev => [...prev, {
|
||||
id: imageId,
|
||||
content: imageUrl,
|
||||
fileName: fileData.file.name
|
||||
}]);
|
||||
|
||||
showToast(`收到图片: ${fileData.file.name}`, "success");
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [fileTransfer.onFileReceived]);
|
||||
|
||||
// 验证并加入房间
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
if (!code || code.length !== 6) return;
|
||||
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
console.log('=== 开始加入房间 ===', code);
|
||||
|
||||
// 验证房间
|
||||
const response = await fetch(`/api/room-info?code=${code}`);
|
||||
const roomData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = roomData.error || '房间不存在或已过期';
|
||||
|
||||
// 特殊处理房间人数已满的情况
|
||||
if (roomData.message?.includes('房间人数已满') || roomData.message?.includes('正在传输中无法加入')) {
|
||||
errorMessage = '当前房间人数已满,正在传输中无法加入,请稍后再试';
|
||||
} else if (roomData.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (roomData.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
if (roomData.is_room_full) {
|
||||
throw new Error('当前房间人数已满,正在传输中无法加入,请稍后再试');
|
||||
}
|
||||
|
||||
console.log('=== 房间验证成功 ===', roomData);
|
||||
setPickupCode(code);
|
||||
|
||||
// 连接到房间
|
||||
await connectAll(code, 'receiver');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('加入房间失败:', error);
|
||||
showToast(error.message || '加入房间失败', "error");
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [connectAll, showToast]);
|
||||
|
||||
// 复制文本到剪贴板
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast('已复制到剪贴板', "success");
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error);
|
||||
showToast('复制失败', "error");
|
||||
}
|
||||
};
|
||||
|
||||
// 处理初始代码连接
|
||||
useEffect(() => {
|
||||
// initialCode isAutoConnected
|
||||
console.log(`initialCode: ${initialCode}, hasTriedAutoConnect: ${hasTriedAutoConnect.current}`);
|
||||
if (initialCode && initialCode.length === 6 && !hasTriedAutoConnect.current) {
|
||||
console.log('=== 自动连接初始代码 ===', initialCode);
|
||||
@@ -201,15 +197,15 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
joinRoom(initialCode);
|
||||
return;
|
||||
}
|
||||
}, [initialCode]);
|
||||
}, [initialCode, joinRoom]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!hasAnyConnection ? (
|
||||
// 输入取件码界面
|
||||
<div>
|
||||
<div className="flex items-center mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex items-center justify-between mb-6 sm:mb-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
@@ -218,6 +214,12 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
<p className="text-sm text-slate-600">请输入6位取件码来获取实时文字内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<ConnectionStatus
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); joinRoom(inputCode); }} className="space-y-4 sm:space-y-6">
|
||||
@@ -225,7 +227,7 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inputCode}
|
||||
onChange={(e) => setInputCode(e.target.value.replace(/[^A-Z0-9]/g, '').toUpperCase())}
|
||||
onChange={(e) => setInputCode(e.target.value.replace(/[^123456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnpqrstuvwxyz]/g, ''))}
|
||||
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}
|
||||
@@ -266,94 +268,114 @@ export const WebRTCTextReceiver: React.FC<WebRTCTextReceiverProps> = ({
|
||||
) : (
|
||||
// 已连接,显示实时文本
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">实时文字内容</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
<span className="text-emerald-600">✅ 已连接,正在实时接收文字</span>
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">取件码: {pickupCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<ConnectionStatus
|
||||
|
||||
{/* 连接成功状态 */}
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6">
|
||||
<h4 className="font-semibold text-emerald-800 mb-1">已连接到文字房间</h4>
|
||||
<p className="text-emerald-700">取件码: {pickupCode}</p>
|
||||
</div>
|
||||
|
||||
{/* 实时文本显示区域 */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-slate-800 flex items-center">
|
||||
<MessageSquare className="w-5 h-5 mr-2" />
|
||||
实时文字内容
|
||||
</h4>
|
||||
<div className="flex items-center space-x-3 text-sm">
|
||||
<span className="text-slate-500">
|
||||
{receivedText.length} / 50,000 字符
|
||||
</span>
|
||||
{textTransfer.isConnected && (
|
||||
<div className="flex items-center space-x-1 bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-medium">WebRTC实时同步</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={receivedText}
|
||||
readOnly
|
||||
placeholder="等待对方发送文字内容... 💡 实时同步显示,对方的编辑会立即显示在这里"
|
||||
className="w-full h-40 px-4 py-3 border border-slate-300 rounded-lg bg-slate-50 text-slate-700 placeholder-slate-400 resize-none"
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'receiver' } : null}
|
||||
/>
|
||||
{!receivedText && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 rounded-lg border border-slate-300">
|
||||
<div className="text-center">
|
||||
<MessageSquare className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
||||
<p className="text-slate-600">等待接收文字内容...</p>
|
||||
<p className="text-sm text-slate-500 mt-2">对方发送的文字将在这里实时显示</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={restart}
|
||||
variant="outline"
|
||||
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
|
||||
>
|
||||
重新开始
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文本显示区域 */}
|
||||
<div className="bg-white/90 backdrop-blur-sm border border-slate-200 rounded-2xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-slate-800 flex items-center space-x-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span>接收到的文字</span>
|
||||
</h4>
|
||||
|
||||
{receivedText && (
|
||||
<Button
|
||||
onClick={() => copyToClipboard(receivedText)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-slate-600 hover:text-slate-800 h-8 px-3"
|
||||
>
|
||||
<span>复制</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 打字状态提示 */}
|
||||
{isTyping && (
|
||||
<div className="flex items-center space-x-2 mt-3 text-sm text-slate-500">
|
||||
<div className="flex space-x-1">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1 h-1 bg-slate-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: `${i * 0.1}s` }}
|
||||
></div>
|
||||
))}
|
||||
<div className="min-h-[200px] bg-slate-50/50 rounded-xl p-4 border border-slate-100 overflow-hidden">
|
||||
{receivedText ? (
|
||||
<div className="space-y-2 h-full">
|
||||
<div className="overflow-auto max-h-[180px]">
|
||||
<pre className="whitespace-pre-wrap break-words text-slate-700 text-sm leading-relaxed font-sans">
|
||||
{receivedText}
|
||||
</pre>
|
||||
</div>
|
||||
{isTyping && (
|
||||
<div className="flex items-center space-x-2 text-slate-500 text-sm">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '0ms'}}></div>
|
||||
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '150ms'}}></div>
|
||||
<div className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" style={{animationDelay: '300ms'}}></div>
|
||||
</div>
|
||||
<span>对方正在输入...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="italic">对方正在输入...</span>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-400 space-y-3">
|
||||
<MessageSquare className="w-12 h-12 text-slate-300" />
|
||||
<p className="text-center">
|
||||
{getConnectState().isPeerConnected ?
|
||||
'等待对方发送文字内容...' :
|
||||
'等待连接建立...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接收到的图片 */}
|
||||
{/* 图片显示区域 */}
|
||||
{receivedImages.length > 0 && (
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-6 border border-slate-200">
|
||||
<h4 className="text-lg font-semibold text-slate-800 mb-4">接收的图片</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div className="bg-white/90 backdrop-blur-sm border border-slate-200 rounded-2xl p-6 space-y-4">
|
||||
<h4 className="font-medium text-slate-800 flex items-center space-x-2">
|
||||
<Image className="w-4 h-4" />
|
||||
<span>接收到的图片 ({receivedImages.length})</span>
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{receivedImages.map((image) => (
|
||||
<img
|
||||
<div
|
||||
key={image.id}
|
||||
src={image.content}
|
||||
alt={image.fileName}
|
||||
className="w-full h-32 object-cover rounded-lg border cursor-pointer hover:opacity-80 transition-opacity"
|
||||
className="group relative aspect-square bg-slate-50 rounded-xl overflow-hidden border border-slate-200 hover:border-slate-300 transition-all duration-200 cursor-pointer"
|
||||
onClick={() => onPreviewImage(image.content)}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
src={image.content}
|
||||
alt={image.fileName || '接收的图片'}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200 flex items-center justify-center">
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="bg-white/90 rounded-lg px-3 py-1">
|
||||
<span className="text-sm text-slate-700">点击查看</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useSharedWebRTCManager } from '@/hooks/webrtc/useSharedWebRTCManager';
|
||||
import { useTextTransferBusiness } from '@/hooks/webrtc/useTextTransferBusiness';
|
||||
import { useFileTransferBusiness } from '@/hooks/webrtc/useFileTransferBusiness';
|
||||
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 QRCodeDisplay from '@/components/QRCodeDisplay';
|
||||
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;
|
||||
onPreviewImage?: (imageUrl: string) => void;
|
||||
onConnectionChange?: (connection: any) => void;
|
||||
}
|
||||
|
||||
export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, onPreviewImage }) => {
|
||||
export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, onPreviewImage, onConnectionChange }) => {
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 状态管理
|
||||
@@ -29,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);
|
||||
@@ -41,15 +45,17 @@ 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;
|
||||
|
||||
// 是否有任何错误
|
||||
const hasAnyError = textTransfer.connectionError || fileTransfer.connectionError;
|
||||
// 通知父组件连接状态变化
|
||||
useEffect(() => {
|
||||
if (onConnectionChange) {
|
||||
onConnectionChange(connection);
|
||||
}
|
||||
}, [onConnectionChange, getConnectState().isConnected, getConnectState().isConnecting, getConnectState().isPeerConnected]);
|
||||
|
||||
|
||||
// 重新开始
|
||||
const restart = () => {
|
||||
@@ -106,21 +112,14 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
|
||||
console.log('=== 开始创建房间 ===');
|
||||
const currentText = textInput.trim();
|
||||
|
||||
// 创建后端房间 - 简化版本,不发送无用的文本信息
|
||||
const response = await fetch('/api/create-room', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'message',
|
||||
initialText: currentText || '',
|
||||
hasImages: false,
|
||||
maxFileSize: 5 * 1024 * 1024,
|
||||
settings: {
|
||||
enableRealTimeText: true,
|
||||
enableImageTransfer: true
|
||||
}
|
||||
}),
|
||||
// 不再发送文本内容,因为后端不使用这些信息
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -138,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);
|
||||
|
||||
@@ -169,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);
|
||||
|
||||
@@ -212,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");
|
||||
@@ -286,36 +285,17 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${textTransfer.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'}`}></div>
|
||||
<span className={textTransfer.isWebSocketConnected ? 'text-blue-600' : 'text-slate-600'}>WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${textTransfer.isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-slate-400'}`}></div>
|
||||
<span className={textTransfer.isConnected ? 'text-emerald-600' : 'text-slate-600'}>RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 连接状态 */}
|
||||
<ConnectionStatus
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
|
||||
<MessageSquare className="w-10 h-10 text-blue-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-800 mb-4">创建文字传输房间</h3>
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">创建文字传输房间</h3>
|
||||
<p className="text-slate-600 mb-8">创建房间后可以实时同步文字内容</p>
|
||||
|
||||
<Button
|
||||
@@ -341,45 +321,22 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
|
||||
// 房间已创建,显示取件码和文本传输界面
|
||||
<div className="space-y-6">
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">传送文字</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{hasAnyConnection ? '实时编辑,对方可以同步看到' : '等待对方连接'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 竖线分割 */}
|
||||
<div className="w-px h-12 bg-slate-200 mx-4"></div>
|
||||
|
||||
{/* 状态显示 */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-500 mb-1">连接状态</div>
|
||||
<div className="flex items-center justify-end space-x-3 text-sm">
|
||||
{/* WebSocket状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${textTransfer.isWebSocketConnected ? 'bg-blue-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<span className={textTransfer.isWebSocketConnected ? 'text-blue-600' : 'text-red-600'}>WS</span>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="text-slate-300">|</div>
|
||||
|
||||
{/* WebRTC状态 */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${textTransfer.isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-orange-400'}`}></div>
|
||||
<span className={textTransfer.isConnected ? 'text-emerald-600' : 'text-orange-600'}>RTC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 功能标题和状态 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
|
||||
{/* 文字编辑区域 - 移到最上面 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">发送文本</h2>
|
||||
<p className="text-sm text-slate-600">输入您想要传输的文本内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionStatus
|
||||
currentRoom={pickupCode ? { code: pickupCode, role: 'sender' } : null}
|
||||
/>
|
||||
</div> {/* 文字编辑区域 - 移到最上面 */}
|
||||
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-slate-800 flex items-center">
|
||||
@@ -402,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"
|
||||
}`}
|
||||
@@ -426,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" />
|
||||
@@ -471,74 +427,24 @@ export const WebRTCTextSender: React.FC<WebRTCTextSenderProps> = ({ onRestart, o
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 取件码显示 - 和文件传输一致的风格 */}
|
||||
<div className="border-t border-slate-200 pt-6">
|
||||
{/* 左上角状态提示 - 类似已选择文件的风格 */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-800">取件码生成成功!</h3>
|
||||
<p className="text-sm text-slate-600">分享以下信息给接收方</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间区域:取件码 + 分隔线 + 二维码 */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start gap-6 lg:gap-8 mb-8">
|
||||
{/* 左侧:取件码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">取件码</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<div className="text-2xl font-bold font-mono bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent tracking-wider">
|
||||
{pickupCode}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={copyCode}
|
||||
className="w-full px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3"
|
||||
>
|
||||
复制取件码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 - 大屏幕显示竖线,移动端隐藏 */}
|
||||
<div className="hidden lg:block w-px bg-slate-200 h-64 mt-6"></div>
|
||||
|
||||
{/* 右侧:二维码 */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">扫码传输</label>
|
||||
<div className="flex flex-col items-center rounded-xl border border-slate-200 p-6 h-40 justify-center bg-slate-50">
|
||||
<QRCodeDisplay
|
||||
value={pickupLink}
|
||||
size={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium shadow transition-all duration-200 mt-3 text-center">
|
||||
使用手机扫码快速访问
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:取件链接 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 code-display rounded-lg p-3 bg-slate-50 border border-slate-200">
|
||||
<div className="text-sm text-slate-700 break-all font-mono leading-relaxed">
|
||||
{pickupLink}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={copyShareLink}
|
||||
className="px-4 py-2.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium shadow transition-all duration-200 shrink-0"
|
||||
>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 取件码显示 */}
|
||||
<RoomInfoDisplay
|
||||
code={pickupCode}
|
||||
link={pickupLink}
|
||||
icon={MessageSquare}
|
||||
iconColor="from-emerald-500 to-teal-500"
|
||||
codeColor="from-emerald-600 to-teal-600"
|
||||
title="取件码生成成功!"
|
||||
subtitle="分享以下信息给接收方"
|
||||
codeLabel="取件码"
|
||||
qrLabel="扫码传输"
|
||||
copyButtonText="复制取件码"
|
||||
copyButtonColor="bg-emerald-500 hover:bg-emerald-600"
|
||||
qrButtonText="使用手机扫码快速访问"
|
||||
linkButtonText="复制链接"
|
||||
onCopyCode={copyCode}
|
||||
onCopyLink={copyShareLink}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
8
chuan-next/src/hooks/connection/index.ts
Normal file
8
chuan-next/src/hooks/connection/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// 连接相关的hooks
|
||||
export { useConnectionState } from './useConnectionState';
|
||||
export { useConnectManager } from './useConnectManager';
|
||||
export { useRoomConnection } from './useRoomConnection';
|
||||
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;
|
||||
};
|
||||
}
|
||||
137
chuan-next/src/hooks/connection/useConnectionState.ts
Normal file
137
chuan-next/src/hooks/connection/useConnectionState.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface UseConnectionStateProps {
|
||||
isWebSocketConnected: boolean;
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
error: string;
|
||||
pickupCode: string;
|
||||
fileListLength: number;
|
||||
currentTransferFile: any;
|
||||
setCurrentTransferFile: (file: any) => void;
|
||||
updateFileListStatus: (callback: (prev: any[]) => any[]) => void;
|
||||
}
|
||||
|
||||
export const useConnectionState = ({
|
||||
isWebSocketConnected,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
error,
|
||||
pickupCode,
|
||||
fileListLength,
|
||||
currentTransferFile,
|
||||
setCurrentTransferFile,
|
||||
updateFileListStatus
|
||||
}: UseConnectionStateProps) => {
|
||||
const { showToast } = useToast();
|
||||
const [lastError, setLastError] = useState<string>('');
|
||||
|
||||
// 处理连接错误
|
||||
useEffect(() => {
|
||||
if (error && error !== lastError) {
|
||||
console.log('=== 连接错误处理 ===');
|
||||
console.log('错误信息:', error);
|
||||
|
||||
// 根据错误类型显示不同的提示
|
||||
let errorMessage = error;
|
||||
|
||||
if (error.includes('WebSocket')) {
|
||||
errorMessage = '服务器连接失败,请检查网络连接或稍后重试';
|
||||
} else if (error.includes('数据通道')) {
|
||||
errorMessage = '数据通道连接失败,请重新尝试连接';
|
||||
} else if (error.includes('连接超时')) {
|
||||
errorMessage = '连接超时,请检查网络状况或重新尝试';
|
||||
} else if (error.includes('连接失败')) {
|
||||
errorMessage = 'WebRTC连接失败,可能是网络环境限制,请尝试刷新页面';
|
||||
} else if (error.includes('信令错误')) {
|
||||
errorMessage = '信令服务器错误,请稍后重试';
|
||||
} else if (error.includes('创建连接失败')) {
|
||||
errorMessage = '无法建立P2P连接,请检查网络设置';
|
||||
}
|
||||
|
||||
// 显示错误提示
|
||||
showToast(errorMessage, "error");
|
||||
setLastError(error);
|
||||
|
||||
// 如果是严重连接错误,清理传输状态
|
||||
if (error.includes('连接失败') || error.includes('数据通道连接失败') || error.includes('WebSocket')) {
|
||||
console.log('严重连接错误,清理传输状态');
|
||||
setCurrentTransferFile(null);
|
||||
|
||||
// 重置所有正在传输的文件状态
|
||||
updateFileListStatus((prev: any[]) => prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
));
|
||||
}
|
||||
}
|
||||
}, [error, lastError, showToast, setCurrentTransferFile, updateFileListStatus]);
|
||||
|
||||
// 监听连接状态变化和清理传输状态
|
||||
useEffect(() => {
|
||||
console.log('=== 连接状态变化 ===');
|
||||
console.log('WebSocket连接状态:', isWebSocketConnected);
|
||||
console.log('WebRTC连接状态:', isConnected);
|
||||
console.log('连接中状态:', isConnecting);
|
||||
|
||||
// 当连接断开或有错误时,清理所有传输状态
|
||||
const shouldCleanup = (!isWebSocketConnected && !isConnected && !isConnecting && pickupCode) ||
|
||||
((!isConnected && !isConnecting) || error);
|
||||
|
||||
if (shouldCleanup) {
|
||||
const hasCurrentTransfer = !!currentTransferFile;
|
||||
const hasFileList = fileListLength > 0;
|
||||
|
||||
// 只有在之前有连接活动时才显示断开提示和清理状态
|
||||
if (hasFileList || hasCurrentTransfer) {
|
||||
if (!isWebSocketConnected && pickupCode) {
|
||||
showToast('与服务器的连接已断开,请重新连接', "error");
|
||||
}
|
||||
|
||||
console.log('连接断开,清理传输状态');
|
||||
|
||||
if (currentTransferFile) {
|
||||
setCurrentTransferFile(null);
|
||||
}
|
||||
|
||||
// 重置所有正在下载的文件状态
|
||||
updateFileListStatus((prev: any[]) => {
|
||||
const hasDownloadingFiles = prev.some(item => item.status === 'downloading');
|
||||
if (hasDownloadingFiles) {
|
||||
console.log('重置正在传输的文件状态');
|
||||
return prev.map(item =>
|
||||
item.status === 'downloading'
|
||||
? { ...item, status: 'ready' as const, progress: 0 }
|
||||
: item
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket连接成功时的提示
|
||||
if (isWebSocketConnected && isConnecting && !isConnected) {
|
||||
console.log('WebSocket已连接,正在建立P2P连接...');
|
||||
}
|
||||
|
||||
}, [isWebSocketConnected, isConnected, isConnecting, pickupCode, error, showToast, currentTransferFile, fileListLength, setCurrentTransferFile, updateFileListStatus]);
|
||||
|
||||
// 监听连接状态变化并提供日志
|
||||
useEffect(() => {
|
||||
console.log('=== WebRTC连接状态变化 ===');
|
||||
console.log('连接状态:', {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isWebSocketConnected,
|
||||
pickupCode,
|
||||
fileListLength
|
||||
});
|
||||
}, [isConnected, isConnecting, isWebSocketConnected, pickupCode, fileListLength]);
|
||||
|
||||
return {
|
||||
lastError
|
||||
};
|
||||
};
|
||||
111
chuan-next/src/hooks/connection/useRoomConnection.ts
Normal file
111
chuan-next/src/hooks/connection/useRoomConnection.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Role } from './types';
|
||||
|
||||
interface UseRoomConnectionProps {
|
||||
connect: (code: string, role: Role) => void;
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export const useRoomConnection = ({ connect, isConnecting, isConnected }: UseRoomConnectionProps) => {
|
||||
const { showToast } = useToast();
|
||||
const [isJoiningRoom, setIsJoiningRoom] = useState(false);
|
||||
|
||||
const validateRoomCode = (code: string): string | null => {
|
||||
const trimmedCode = code.trim();
|
||||
if (!trimmedCode || trimmedCode.length !== 6) {
|
||||
return '请输入正确的6位取件码';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const checkRoomStatus = async (code: string) => {
|
||||
const response = await fetch(`/api/room-info?code=${code}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: 无法检查房间状态`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
let errorMessage = result.message || '房间不存在或已过期';
|
||||
if (result.message?.includes('expired')) {
|
||||
errorMessage = '房间已过期,请联系发送方重新创建';
|
||||
} else if (result.message?.includes('not found')) {
|
||||
errorMessage = '房间不存在,请检查取件码是否正确';
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 检查房间是否已满
|
||||
if (result.is_room_full) {
|
||||
throw new Error('当前房间人数已满,正在传输中无法加入');
|
||||
}
|
||||
|
||||
if (!result.sender_online) {
|
||||
throw new Error('发送方不在线,请确认取件码是否正确或联系发送方');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleNetworkError = (error: Error): string => {
|
||||
if (error.message.includes('network') || error.message.includes('fetch')) {
|
||||
return '网络连接失败,请检查网络状况';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
return '请求超时,请重试';
|
||||
} else if (error.message.includes('HTTP 404')) {
|
||||
return '房间不存在,请检查取件码';
|
||||
} else if (error.message.includes('HTTP 500')) {
|
||||
return '服务器错误,请稍后重试';
|
||||
} else if (error.message.includes('房间人数已满') || error.message.includes('正在传输中无法加入')) {
|
||||
return '当前房间人数已满,正在传输中无法加入,请稍后再试';
|
||||
} else {
|
||||
return error.message;
|
||||
}
|
||||
};
|
||||
|
||||
// 加入房间 (接收模式)
|
||||
const joinRoom = useCallback(async (code: string) => {
|
||||
console.log('=== 加入房间 ===');
|
||||
console.log('取件码:', code);
|
||||
|
||||
// 验证输入
|
||||
const validationError = validateRoomCode(code);
|
||||
if (validationError) {
|
||||
showToast(validationError, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复调用
|
||||
if (isConnecting || isConnected || isJoiningRoom) {
|
||||
console.log('已在连接中或已连接,跳过重复的房间状态检查');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsJoiningRoom(true);
|
||||
|
||||
try {
|
||||
console.log('检查房间状态...');
|
||||
await checkRoomStatus(code.trim());
|
||||
|
||||
console.log('房间状态检查通过,开始连接...');
|
||||
connect(code.trim(), 'receiver');
|
||||
showToast(`正在连接到房间: ${code.trim()}`, "success");
|
||||
|
||||
} catch (error) {
|
||||
console.error('检查房间状态失败:', error);
|
||||
const errorMessage = error instanceof Error ? handleNetworkError(error) : '检查房间状态失败';
|
||||
showToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsJoiningRoom(false);
|
||||
}
|
||||
}, [isConnecting, isConnected, isJoiningRoom, showToast, connect]);
|
||||
|
||||
return {
|
||||
joinRoom,
|
||||
isJoiningRoom
|
||||
};
|
||||
};
|
||||
40
chuan-next/src/hooks/connection/useWebRTCSupport.ts
Normal file
40
chuan-next/src/hooks/connection/useWebRTCSupport.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { detectWebRTCSupport, WebRTCSupport } from '@/lib/webrtc-support';
|
||||
|
||||
/**
|
||||
* WebRTC 支持检测 Hook
|
||||
*/
|
||||
export function useWebRTCSupport() {
|
||||
const [webrtcSupport, setWebrtcSupport] = useState<WebRTCSupport | null>(null);
|
||||
const [showUnsupportedModal, setShowUnsupportedModal] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 页面加载时检测WebRTC支持
|
||||
const support = detectWebRTCSupport();
|
||||
setWebrtcSupport(support);
|
||||
setIsChecked(true);
|
||||
|
||||
// 如果不支持,自动显示模态框
|
||||
if (!support.isSupported) {
|
||||
setShowUnsupportedModal(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeUnsupportedModal = () => {
|
||||
setShowUnsupportedModal(false);
|
||||
};
|
||||
|
||||
const showUnsupportedModalManually = () => {
|
||||
setShowUnsupportedModal(true);
|
||||
};
|
||||
|
||||
return {
|
||||
webrtcSupport,
|
||||
isSupported: webrtcSupport?.isSupported ?? false,
|
||||
isChecked,
|
||||
showUnsupportedModal,
|
||||
closeUnsupportedModal,
|
||||
showUnsupportedModalManually,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { IWebConnectStateManager } from '../state/useWebConnectStateManager';
|
||||
import { DataHandler, IRegisterEventHandler, IWebMessage, MessageHandler, Role, WebRTCDataChannelManager } from '../types';
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* WebRTC 数据通道管理 Hook
|
||||
* 负责数据通道的创建和管理,处理数据通道消息的发送和接收
|
||||
*/
|
||||
export function useWebRTCDataChannelManager(
|
||||
stateManager: IWebConnectStateManager
|
||||
): WebRTCDataChannelManager & IRegisterEventHandler {
|
||||
const dcRef = useRef<RTCDataChannel | null>(null);
|
||||
const stateManagerRef = useRef(stateManager);
|
||||
stateManagerRef.current = stateManager;
|
||||
|
||||
// 多通道消息处理器
|
||||
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
|
||||
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
|
||||
|
||||
|
||||
// 创建数据通道
|
||||
const createDataChannel = useCallback((
|
||||
pc: RTCPeerConnection,
|
||||
role: Role,
|
||||
isReconnect: boolean = false
|
||||
) => {
|
||||
console.log('[DataChannelManager] 创建数据通道...', { role, isReconnect });
|
||||
|
||||
// 如果已经存在数据通道,先关闭它
|
||||
if (dcRef.current) {
|
||||
console.log('[DataChannelManager] 关闭已存在的数据通道');
|
||||
dcRef.current.close();
|
||||
dcRef.current = null;
|
||||
}
|
||||
|
||||
// 数据通道处理
|
||||
if (role === 'sender') {
|
||||
const dataChannel = pc.createDataChannel('shared-channel', {
|
||||
ordered: true,
|
||||
maxRetransmits: 3
|
||||
});
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = (event) => {
|
||||
console.log('[DataChannelManager] 数据通道已打开 (发送方)');
|
||||
// 确保所有连接状态都正确更新
|
||||
stateManagerRef.current.updateState({
|
||||
isDataChannelConnected: true,
|
||||
isConnected: true,
|
||||
isPeerConnected: true,
|
||||
error: null,
|
||||
isConnecting: false,
|
||||
canRetry: false,
|
||||
state: 'open',
|
||||
stateMsg: "数据通道已打开"
|
||||
});
|
||||
|
||||
// 如果是重新连接,触发数据同步
|
||||
if (isReconnect) {
|
||||
console.log('[DataChannelManager] 发送方重新连接,数据通道已打开,准备同步数据');
|
||||
// 发送同步请求消息
|
||||
setTimeout(() => {
|
||||
if (dataChannel.readyState === 'open') {
|
||||
dataChannel.send(JSON.stringify({
|
||||
type: 'sync-request',
|
||||
payload: { timestamp: Date.now() }
|
||||
}));
|
||||
console.log('[DataChannelManager] 发送方发送数据同步请求');
|
||||
}
|
||||
}, 300); // 等待数据通道完全稳定
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[DataChannelManager] 数据通道错误:', error);
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '数据通道连接失败';
|
||||
let shouldRetry = false;
|
||||
|
||||
// 根据数据通道状态提供更具体的错误信息
|
||||
switch (dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
errorMessage = '数据通道正在连接中,请稍候...';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'closing':
|
||||
errorMessage = '数据通道正在关闭,连接即将断开';
|
||||
break;
|
||||
case 'closed':
|
||||
errorMessage = '数据通道已关闭,P2P连接失败';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
// 检查PeerConnection状态
|
||||
if (pc) {
|
||||
switch (pc.connectionState) {
|
||||
case 'failed':
|
||||
errorMessage = 'P2P连接失败,可能是网络防火墙阻止了连接,请尝试切换网络或使用VPN';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'disconnected':
|
||||
errorMessage = 'P2P连接已断开,网络可能不稳定';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
errorMessage = '数据通道连接失败,可能是网络环境受限';
|
||||
shouldRetry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[DataChannelManager] 数据通道详细错误 - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
|
||||
|
||||
stateManagerRef.current.updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isPeerConnected: false, // 数据通道出错时,P2P连接肯定不可用
|
||||
isDataChannelConnected: false,
|
||||
canRetry: shouldRetry // 设置是否可以重试
|
||||
});
|
||||
};
|
||||
} else {
|
||||
pc.ondatachannel = (event) => {
|
||||
const dataChannel = event.channel;
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = (event) => {
|
||||
console.log('[DataChannelManager] 数据通道已打开 (接收方)');
|
||||
// 确保所有连接状态都正确更新
|
||||
stateManagerRef.current.updateState({
|
||||
isWebSocketConnected: true,
|
||||
isDataChannelConnected: true,
|
||||
isConnected: true,
|
||||
isPeerConnected: true,
|
||||
error: null,
|
||||
isConnecting: false,
|
||||
canRetry: false,
|
||||
state: 'open'
|
||||
});
|
||||
|
||||
// 如果是重新连接,触发数据同步
|
||||
if (isReconnect) {
|
||||
console.log('[DataChannelManager] 接收方重新连接,数据通道已打开,准备同步数据');
|
||||
// 发送同步请求消息
|
||||
setTimeout(() => {
|
||||
if (dataChannel.readyState === 'open') {
|
||||
dataChannel.send(JSON.stringify({
|
||||
type: 'sync-request',
|
||||
payload: { timestamp: Date.now() }
|
||||
}));
|
||||
console.log('[DataChannelManager] 接收方发送数据同步请求');
|
||||
}
|
||||
}, 300); // 等待数据通道完全稳定
|
||||
}
|
||||
};
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[DataChannelManager] 数据通道错误 (接收方):', error);
|
||||
|
||||
// 获取更详细的错误信息
|
||||
let errorMessage = '数据通道连接失败';
|
||||
let shouldRetry = false;
|
||||
|
||||
// 根据数据通道状态提供更具体的错误信息
|
||||
switch (dataChannel.readyState) {
|
||||
case 'connecting':
|
||||
errorMessage = '数据通道正在连接中,请稍候...';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'closing':
|
||||
errorMessage = '数据通道正在关闭,连接即将断开';
|
||||
break;
|
||||
case 'closed':
|
||||
errorMessage = '数据通道已关闭,P2P连接失败';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
// 检查PeerConnection状态
|
||||
if (pc) {
|
||||
switch (pc.connectionState) {
|
||||
case 'failed':
|
||||
errorMessage = 'P2P连接失败,可能是网络防火墙阻止了连接,请尝试切换网络或使用VPN';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
case 'disconnected':
|
||||
errorMessage = 'P2P连接已断开,网络可能不稳定';
|
||||
shouldRetry = true;
|
||||
break;
|
||||
default:
|
||||
errorMessage = '数据通道连接失败,可能是网络环境受限';
|
||||
shouldRetry = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[DataChannelManager] 数据通道详细错误 (接收方) - 状态: ${dataChannel.readyState}, 消息: ${errorMessage}, 建议重试: ${shouldRetry}`);
|
||||
|
||||
stateManagerRef.current.updateState({
|
||||
error: errorMessage,
|
||||
isConnecting: false,
|
||||
isPeerConnected: false, // 数据通道出错时,P2P连接肯定不可用
|
||||
isDataChannelConnected: false,
|
||||
canRetry: shouldRetry // 设置是否可以重试
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[DataChannelManager] 数据通道创建完成,角色:', role, '是否重新连接:', isReconnect);
|
||||
}, [stateManager]);
|
||||
|
||||
// 处理数据通道消息
|
||||
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
|
||||
console.log('[DataChannelManager] 收到数据通道消息,类型:', typeof event.data);
|
||||
console.log('[DataChannelManager] 数据通道当前状态:', messageHandlers.current);
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as IWebMessage;
|
||||
console.log('[DataChannelManager] 收到消息:', message.type, message.channel || 'default');
|
||||
|
||||
// 根据通道分发消息
|
||||
if (message.channel) {
|
||||
const handler = messageHandlers.current.get(message.channel);
|
||||
if (handler) {
|
||||
handler(message);
|
||||
}
|
||||
} else {
|
||||
// 兼容旧版本,广播给所有处理器
|
||||
messageHandlers.current.forEach(handler => handler(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DataChannelManager] 解析消息失败:', error);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
console.log('[DataChannelManager] 收到数据:', event.data.byteLength, 'bytes');
|
||||
|
||||
// 数据优先发给文件传输处理器
|
||||
const fileHandler = dataHandlers.current.get('file-transfer');
|
||||
if (fileHandler) {
|
||||
fileHandler(event.data);
|
||||
} else {
|
||||
// 如果没有文件处理器,发给第一个处理器
|
||||
const firstHandler = dataHandlers.current.values().next().value;
|
||||
if (firstHandler) {
|
||||
firstHandler(event.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 注册消息处理器
|
||||
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
|
||||
console.log('[DataChannelManager] 注册消息处理器:', channel);
|
||||
messageHandlers.current.set(channel, handler);
|
||||
return () => {
|
||||
console.log('[DataChannelManager] 取消注册消息处理器:', channel);
|
||||
messageHandlers.current.delete(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 注册数据处理器
|
||||
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
|
||||
console.log('[DataChannelManager] 注册数据处理器:', channel);
|
||||
dataHandlers.current.set(channel, handler);
|
||||
|
||||
return () => {
|
||||
console.log('[DataChannelManager] 取消注册数据处理器:', channel);
|
||||
dataHandlers.current.delete(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((message: IWebMessage, channel?: string) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('[DataChannelManager] 数据通道未准备就绪');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const messageWithChannel = channel ? { ...message, channel } : message;
|
||||
dataChannel.send(JSON.stringify(messageWithChannel));
|
||||
console.log('[DataChannelManager] 发送消息:', message.type, channel || 'default');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DataChannelManager] 发送消息失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 发送二进制数据
|
||||
const sendData = useCallback((data: ArrayBuffer) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('[DataChannelManager] 数据通道未准备就绪');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
dataChannel.send(data);
|
||||
console.log('[DataChannelManager] 发送数据:', data.byteLength, 'bytes');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DataChannelManager] 发送数据失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
// 获取数据通道状态
|
||||
const getChannelState = useCallback(() => {
|
||||
return stateManagerRef.current.getState();
|
||||
}, []);
|
||||
|
||||
// 实时更新数据通道状态
|
||||
useEffect(() => {
|
||||
const updateChannelState = () => {
|
||||
const readyState = dcRef.current?.readyState || 'closed';
|
||||
console.log('[DataChannelManager] 数据通道状态更新:', readyState);
|
||||
|
||||
// 更新状态存储中的数据通道状态
|
||||
stateManagerRef.current.updateState({
|
||||
state: readyState,
|
||||
isDataChannelConnected: readyState === 'open'
|
||||
});
|
||||
};
|
||||
|
||||
// 立即更新一次
|
||||
updateChannelState();
|
||||
|
||||
// 如果数据通道存在,设置状态变化监听
|
||||
if (dcRef.current) {
|
||||
const dc = dcRef.current;
|
||||
const originalOnOpen = dc.onopen;
|
||||
const originalOnClose = dc.onclose;
|
||||
const originalOnError = dc.onerror;
|
||||
|
||||
dc.onopen = (event) => {
|
||||
console.log('[DataChannelManager] 数据通道打开事件触发');
|
||||
updateChannelState();
|
||||
if (originalOnOpen) originalOnOpen.call(dc, event);
|
||||
};
|
||||
|
||||
dc.onclose = (event) => {
|
||||
console.log('[DataChannelManager] 数据通道关闭事件触发');
|
||||
updateChannelState();
|
||||
if (originalOnClose) originalOnClose.call(dc, event);
|
||||
};
|
||||
|
||||
dc.onerror = (error) => {
|
||||
console.log('[DataChannelManager] 数据通道错误事件触发');
|
||||
updateChannelState();
|
||||
if (originalOnError) originalOnError.call(dc, error);
|
||||
};
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (dcRef.current) {
|
||||
// 恢复原始事件处理器
|
||||
// 注意:在实际应用中,可能需要更复杂的事件处理器管理
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
createDataChannel,
|
||||
sendMessage,
|
||||
sendData,
|
||||
getConnectState: getChannelState,
|
||||
registerDataHandler,
|
||||
registerMessageHandler,
|
||||
handleDataChannelMessage,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
4
chuan-next/src/hooks/desktop-share/index.ts
Normal file
4
chuan-next/src/hooks/desktop-share/index.ts
Normal file
@@ -0,0 +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;
|
||||
}
|
||||
759
chuan-next/src/hooks/desktop-share/useDesktopShareBusiness.ts
Normal file
759
chuan-next/src/hooks/desktop-share/useDesktopShareBusiness.ts
Normal file
@@ -0,0 +1,759 @@
|
||||
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 = useConnectManager();
|
||||
const [state, setState] = useState<DesktopShareState>({
|
||||
isSharing: false,
|
||||
isViewing: false,
|
||||
connectionCode: '',
|
||||
remoteStream: null,
|
||||
localStream: null,
|
||||
error: null,
|
||||
isWaitingForPeer: false,
|
||||
});
|
||||
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const currentSenderRef = useRef<RTCRtpSender | null>(null);
|
||||
|
||||
const updateState = useCallback((updates: Partial<DesktopShareState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 处理远程流
|
||||
const handleRemoteStream = useCallback((stream: MediaStream) => {
|
||||
console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道');
|
||||
setState(prev => ({ ...prev, remoteStream: stream }));
|
||||
|
||||
// 如果有视频元素引用,设置流
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = stream;
|
||||
}
|
||||
}, []); // 移除updateState依赖,直接使用setState
|
||||
|
||||
// 设置远程轨道处理器(始终监听)
|
||||
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);
|
||||
|
||||
// 只提取视频轨道创建新的视频流
|
||||
const videoTracks = remoteStream.getVideoTracks();
|
||||
if (videoTracks.length > 0) {
|
||||
const videoStream = new MediaStream(videoTracks);
|
||||
console.log('[DesktopShare] 📹 创建纯视频流,视频轨道数:', videoTracks.length);
|
||||
|
||||
videoTracks.forEach(track => {
|
||||
console.log('[DesktopShare] 视频轨道:', track.id, '启用:', track.enabled, '状态:', track.readyState);
|
||||
// 确保轨道已启用
|
||||
if (!track.enabled) {
|
||||
console.log('[DesktopShare] 🔓 启用视频轨道:', track.id);
|
||||
track.enabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 直接使用setState
|
||||
setState(prev => ({ ...prev, remoteStream: videoStream }));
|
||||
|
||||
// 如果有视频元素引用,设置流
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = videoStream;
|
||||
}
|
||||
}
|
||||
} 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> => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
cursor: 'always',
|
||||
displaySurface: 'monitor',
|
||||
} as DisplayMediaStreamOptions['video'],
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
} as DisplayMediaStreamOptions['audio'],
|
||||
});
|
||||
|
||||
console.log('[DesktopShare] 获取桌面流成功:', stream.getTracks().length, '个轨道');
|
||||
return stream;
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 获取桌面流失败:', error);
|
||||
throw new Error('无法获取桌面共享权限,请确保允许屏幕共享');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 设置视频轨道发送
|
||||
const setupVideoSending = useCallback(async (stream: MediaStream) => {
|
||||
console.log('[DesktopShare] 🎬 开始设置视频轨道发送...');
|
||||
|
||||
// 检查P2P连接状态
|
||||
if (!webRTC.getConnectState().isPeerConnected) {
|
||||
console.warn('[DesktopShare] ⚠️ P2P连接尚未完全建立,等待连接稳定...');
|
||||
// 等待连接稳定
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 再次检查
|
||||
if (!webRTC.getConnectState().isPeerConnected) {
|
||||
console.error('[DesktopShare] ❌ P2P连接仍未建立,无法开始媒体传输');
|
||||
throw new Error('P2P连接尚未建立');
|
||||
}
|
||||
}
|
||||
|
||||
// 移除之前的轨道(如果存在)
|
||||
if (currentSenderRef.current) {
|
||||
console.log('[DesktopShare] 🗑️ 移除之前的视频轨道');
|
||||
webRTC.removeTrack(currentSenderRef.current);
|
||||
currentSenderRef.current = null;
|
||||
}
|
||||
|
||||
// 添加新的视频轨道到PeerConnection
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
|
||||
if (videoTrack) {
|
||||
console.log('[DesktopShare] 📹 添加视频轨道:', videoTrack.id, videoTrack.readyState);
|
||||
const videoSender = webRTC.addTrack(videoTrack, stream);
|
||||
if (videoSender) {
|
||||
currentSenderRef.current = videoSender;
|
||||
console.log('[DesktopShare] ✅ 视频轨道添加成功');
|
||||
} else {
|
||||
console.warn('[DesktopShare] ⚠️ 视频轨道添加返回null');
|
||||
}
|
||||
} else {
|
||||
console.error('[DesktopShare] ❌ 未找到视频轨道');
|
||||
throw new Error('未找到视频轨道');
|
||||
}
|
||||
|
||||
if (audioTrack) {
|
||||
try {
|
||||
console.log('[DesktopShare] 🎵 添加音频轨道:', audioTrack.id, audioTrack.readyState);
|
||||
const audioSender = webRTC.addTrack(audioTrack, stream);
|
||||
if (audioSender) {
|
||||
console.log('[DesktopShare] ✅ 音频轨道添加成功');
|
||||
} else {
|
||||
console.warn('[DesktopShare] ⚠️ 音频轨道添加返回null');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[DesktopShare] ⚠️ 音频轨道添加失败,继续视频共享:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('[DesktopShare] ℹ️ 未检测到音频轨道(这通常是正常的)');
|
||||
}
|
||||
|
||||
// 轨道添加完成,现在需要重新协商以包含媒体轨道
|
||||
console.log('[DesktopShare] ✅ 桌面共享轨道添加完成,开始重新协商');
|
||||
|
||||
// 获取PeerConnection实例以便调试
|
||||
const pc = webRTC.getPeerConnection();
|
||||
if (pc) {
|
||||
console.log('[DesktopShare] 🔍 当前连接状态:', {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
signalingState: pc.signalingState,
|
||||
senders: pc.getSenders().length
|
||||
});
|
||||
}
|
||||
|
||||
// 等待一小段时间确保轨道完全添加
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 创建新的offer包含媒体轨道
|
||||
console.log('[DesktopShare] 📨 创建包含媒体轨道的新offer进行重新协商');
|
||||
const success = await webRTC.createOfferNow();
|
||||
if (success) {
|
||||
console.log('[DesktopShare] ✅ 媒体轨道重新协商成功');
|
||||
|
||||
// 等待重新协商完成
|
||||
console.log('[DesktopShare] ⏳ 等待重新协商完成...');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// 检查连接状态
|
||||
if (pc) {
|
||||
console.log('[DesktopShare] 🔍 重新协商后连接状态:', {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
signalingState: pc.signalingState
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error('[DesktopShare] ❌ 媒体轨道重新协商失败');
|
||||
throw new Error('媒体轨道重新协商失败');
|
||||
}
|
||||
|
||||
// 监听流结束事件(用户停止共享)
|
||||
const handleStreamEnded = () => {
|
||||
console.log('[DesktopShare] 🛑 用户停止了屏幕共享');
|
||||
stopSharing();
|
||||
};
|
||||
|
||||
videoTrack?.addEventListener('ended', handleStreamEnded);
|
||||
audioTrack?.addEventListener('ended', handleStreamEnded);
|
||||
|
||||
return () => {
|
||||
videoTrack?.removeEventListener('ended', handleStreamEnded);
|
||||
audioTrack?.removeEventListener('ended', handleStreamEnded);
|
||||
};
|
||||
}, [webRTC]);
|
||||
|
||||
// 创建房间 - 统一使用后端生成房间码
|
||||
const createRoomFromBackend = useCallback(async (): Promise<string> => {
|
||||
const response = await fetch('/api/create-room', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '创建房间失败');
|
||||
}
|
||||
|
||||
return data.code;
|
||||
}, []);
|
||||
|
||||
// 创建房间并立即开始桌面共享
|
||||
const createRoomAndStartSharing = 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,
|
||||
}));
|
||||
|
||||
// 立即开始桌面共享(不等待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);
|
||||
setState(prev => ({ ...prev, error: errorMessage, connectionCode: '', isWaitingForPeer: false }));
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, createRoomFromBackend]); // 移除updateState依赖
|
||||
|
||||
// 开始桌面共享(支持有或无P2P连接状态)
|
||||
const startSharing = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
console.log('[DesktopShare] 📺 正在请求桌面共享权限...');
|
||||
|
||||
// 获取桌面流
|
||||
const stream = await getDesktopStream();
|
||||
|
||||
// 停止之前的流(如果有)
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
localStreamRef.current = stream;
|
||||
console.log('[DesktopShare] ✅ 桌面流获取成功,流ID:', stream.id, '轨道数:', stream.getTracks().length);
|
||||
|
||||
// 如果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);
|
||||
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依赖
|
||||
|
||||
// 切换桌面共享(重新选择屏幕)
|
||||
const switchDesktop = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (!state.isSharing) {
|
||||
throw new Error('当前未在共享桌面');
|
||||
}
|
||||
|
||||
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] ✅ 新桌面流获取成功,流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);
|
||||
setState(prev => ({ ...prev, error: errorMessage }));
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, state.isSharing, getDesktopStream]); // 移除setupVideoSending和updateState依赖
|
||||
|
||||
// 停止桌面共享
|
||||
const stopSharing = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
console.log('[DesktopShare] 停止桌面共享');
|
||||
|
||||
// 停止本地流
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => {
|
||||
track.stop();
|
||||
console.log('[DesktopShare] 停止轨道:', track.kind);
|
||||
});
|
||||
localStreamRef.current = null;
|
||||
}
|
||||
|
||||
// 移除发送器
|
||||
if (currentSenderRef.current) {
|
||||
webRTC.removeTrack(currentSenderRef.current);
|
||||
currentSenderRef.current = null;
|
||||
}
|
||||
|
||||
// 断开WebRTC连接
|
||||
webRTC.disconnect();
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSharing: false,
|
||||
connectionCode: '',
|
||||
error: null,
|
||||
isWaitingForPeer: false,
|
||||
localStream: null,
|
||||
}));
|
||||
|
||||
console.log('[DesktopShare] 桌面共享已停止');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
|
||||
console.error('[DesktopShare] 停止共享失败:', error);
|
||||
setState(prev => ({ ...prev, error: errorMessage }));
|
||||
}
|
||||
}, [webRTC]); // 移除updateState依赖,直接使用setState
|
||||
|
||||
// 重置桌面共享到初始状态(让用户重新选择桌面)
|
||||
const resetSharing = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
console.log('[DesktopShare] 重置桌面共享到初始状态');
|
||||
|
||||
// 停止本地流
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => {
|
||||
track.stop();
|
||||
console.log('[DesktopShare] 停止轨道:', track.kind);
|
||||
});
|
||||
localStreamRef.current = null;
|
||||
}
|
||||
|
||||
// 移除发送器
|
||||
if (currentSenderRef.current) {
|
||||
webRTC.removeTrack(currentSenderRef.current);
|
||||
currentSenderRef.current = null;
|
||||
}
|
||||
|
||||
// 保留WebSocket连接和房间代码,但重置共享状态
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isSharing: false,
|
||||
error: null,
|
||||
isWaitingForPeer: false,
|
||||
localStream: null,
|
||||
}));
|
||||
|
||||
console.log('[DesktopShare] 桌面共享已重置到初始状态');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '重置桌面共享失败';
|
||||
console.error('[DesktopShare] 重置共享失败:', error);
|
||||
setState(prev => ({ ...prev, error: errorMessage }));
|
||||
}
|
||||
}, [webRTC]); // 移除updateState依赖
|
||||
|
||||
// 处理P2P连接断开但保持桌面共享状态(用于接收方离开房间的情况)
|
||||
const handlePeerDisconnect = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
console.log('[DesktopShare] P2P连接断开,但保持桌面共享状态以便新用户加入');
|
||||
|
||||
// 移除当前的发送器(清理P2P连接相关资源)
|
||||
if (currentSenderRef.current) {
|
||||
webRTC.removeTrack(currentSenderRef.current);
|
||||
currentSenderRef.current = null;
|
||||
}
|
||||
|
||||
// 保持本地流和共享状态,只设置为等待新的对等方
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isWaitingForPeer: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
console.log('[DesktopShare] 已清理P2P连接资源,等待新用户加入');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '处理P2P断开失败';
|
||||
console.error('[DesktopShare] 处理P2P断开失败:', error);
|
||||
setState(prev => ({ ...prev, error: errorMessage }));
|
||||
}
|
||||
}, [webRTC]); // 移除updateState依赖
|
||||
|
||||
// 重新建立P2P连接并推流(用于新用户加入房间的情况)
|
||||
const resumeSharing = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
console.log('[DesktopShare] 新用户加入,重新建立P2P连接并推流');
|
||||
|
||||
// 检查是否还在共享状态且有本地流
|
||||
if (!state.isSharing || !localStreamRef.current) {
|
||||
console.log('[DesktopShare] 当前没有在共享或没有本地流,无法恢复推流');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查P2P连接状态
|
||||
if (!webRTC.getConnectState().isPeerConnected) {
|
||||
console.log('[DesktopShare] P2P连接未建立,等待连接完成');
|
||||
return;
|
||||
}
|
||||
|
||||
// 重新设置视频发送
|
||||
await setupVideoSending(localStreamRef.current);
|
||||
|
||||
updateState({
|
||||
isWaitingForPeer: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
console.log('[DesktopShare] ✅ P2P连接已恢复,桌面共享流已重新建立');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '恢复桌面共享失败';
|
||||
console.error('[DesktopShare] 恢复桌面共享失败:', error);
|
||||
updateState({ error: errorMessage });
|
||||
}
|
||||
}, [webRTC, state.isSharing]); // 移除setupVideoSending和updateState依赖
|
||||
|
||||
// 加入桌面共享观看
|
||||
const joinSharing = useCallback(async (code: string): Promise<void> => {
|
||||
try {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
console.log('[DesktopShare] 🔍 正在加入桌面共享观看:', code);
|
||||
|
||||
// 连接WebRTC
|
||||
console.log('[DesktopShare] 🔗 正在连接WebRTC作为接收方...');
|
||||
await webRTC.connect(code, 'receiver');
|
||||
console.log('[DesktopShare] ✅ WebRTC连接建立完成');
|
||||
|
||||
// 等待连接完全建立
|
||||
console.log('[DesktopShare] ⏳ 等待连接稳定...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 检查连接状态
|
||||
const pc = webRTC.getPeerConnection();
|
||||
if (pc) {
|
||||
console.log('[DesktopShare] 🔍 连接状态:', {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
signalingState: pc.signalingState
|
||||
});
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, isViewing: true }));
|
||||
console.log('[DesktopShare] 👁️ 已进入桌面共享观看模式,等待接收流...');
|
||||
|
||||
// 设置一个超时检查,如果长时间没有收到流,输出警告
|
||||
setTimeout(() => {
|
||||
if (!state.remoteStream) {
|
||||
console.warn('[DesktopShare] ⚠️ 长时间未收到远程流,可能存在连接问题');
|
||||
// 可以在这里添加一些恢复逻辑,比如尝试重新连接
|
||||
}
|
||||
}, 10000); // 10秒后检查
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '加入桌面共享失败';
|
||||
console.error('[DesktopShare] ❌ 加入观看失败:', error);
|
||||
setState(prev => ({ ...prev, error: errorMessage, isViewing: false }));
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, state.remoteStream]); // 移除updateState依赖
|
||||
|
||||
// 停止观看桌面共享
|
||||
const stopViewing = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
console.log('[DesktopShare] 停止观看桌面共享');
|
||||
|
||||
// 断开WebRTC连接
|
||||
webRTC.disconnect();
|
||||
|
||||
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);
|
||||
setState(prev => ({ ...prev, error: errorMessage }));
|
||||
}
|
||||
}, [webRTC]); // 移除updateState依赖
|
||||
|
||||
// 设置远程视频元素引用
|
||||
const setRemoteVideoRef = useCallback((videoElement: HTMLVideoElement | null) => {
|
||||
remoteVideoRef.current = videoElement;
|
||||
if (videoElement && state.remoteStream) {
|
||||
videoElement.srcObject = state.remoteStream;
|
||||
}
|
||||
}, [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 () => {
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isSharing: state.isSharing,
|
||||
isViewing: state.isViewing,
|
||||
connectionCode: state.connectionCode,
|
||||
remoteStream: state.remoteStream,
|
||||
localStream: state.localStream, // 使用状态中的流
|
||||
error: state.error,
|
||||
isWaitingForPeer: state.isWaitingForPeer,
|
||||
isConnected: webRTC.getConnectState().isConnected,
|
||||
isConnecting: webRTC.getConnectState().isConnecting,
|
||||
isWebSocketConnected: webRTC.getConnectState().isWebSocketConnected,
|
||||
isPeerConnected: webRTC.getConnectState().isPeerConnected,
|
||||
// 新增:表示是否可以开始共享(WebSocket已连接且有房间代码)
|
||||
canStartSharing: webRTC.getConnectState().isWebSocketConnected && !!state.connectionCode,
|
||||
|
||||
// 方法
|
||||
createRoom, // 创建房间
|
||||
createRoomAndStartSharing, // 创建房间并立即开始桌面共享
|
||||
startSharing, // 选择桌面并建立P2P连接
|
||||
switchDesktop, // 新增:切换桌面
|
||||
stopSharing,
|
||||
resetSharing, // 重置到初始状态,保留房间连接
|
||||
handlePeerDisconnect, // 处理P2P断开但保持桌面共享
|
||||
resumeSharing, // 重新建立P2P连接并推流
|
||||
joinSharing,
|
||||
stopViewing,
|
||||
setRemoteVideoRef,
|
||||
|
||||
// WebRTC连接状态
|
||||
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,
|
||||
}
|
||||
};
|
||||
}
|
||||
4
chuan-next/src/hooks/file-transfer/index.ts
Normal file
4
chuan-next/src/hooks/file-transfer/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// 文件传输相关的hooks
|
||||
export { useFileTransferBusiness } from './useFileTransferBusiness';
|
||||
export { useFileStateManager } from './useFileStateManager';
|
||||
export { useFileListSync } from './useFileListSync';
|
||||
94
chuan-next/src/hooks/file-transfer/useFileListSync.ts
Normal file
94
chuan-next/src/hooks/file-transfer/useFileListSync.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface UseFileListSyncProps {
|
||||
sendFileList: (fileInfos: FileInfo[]) => void;
|
||||
mode: 'send' | 'receive';
|
||||
pickupCode: string;
|
||||
isConnected: boolean;
|
||||
isPeerConnected: boolean;
|
||||
getChannelState: () => any;
|
||||
}
|
||||
|
||||
export const useFileListSync = ({
|
||||
sendFileList,
|
||||
mode,
|
||||
pickupCode,
|
||||
isConnected,
|
||||
isPeerConnected,
|
||||
getChannelState
|
||||
}: UseFileListSyncProps) => {
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 统一的文件列表同步函数,带防抖功能
|
||||
const syncFileListToReceiver = useCallback((fileInfos: FileInfo[], reason: string) => {
|
||||
// 只有在发送模式、连接已建立且有房间时才发送文件列表
|
||||
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);
|
||||
}
|
||||
|
||||
// 延时发送,避免频繁发送
|
||||
syncTimeoutRef.current = setTimeout(() => {
|
||||
// 检查数据通道状态 - 使用更宽松的条件
|
||||
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]);
|
||||
|
||||
// 清理防抖定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
syncFileListToReceiver
|
||||
};
|
||||
};
|
||||
199
chuan-next/src/hooks/file-transfer/useFileStateManager.ts
Normal file
199
chuan-next/src/hooks/file-transfer/useFileStateManager.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
transferSpeed?: number; // bytes per second
|
||||
startTime?: number; // 传输开始时间
|
||||
}
|
||||
|
||||
interface UseFileStateManagerProps {
|
||||
mode: 'send' | 'receive';
|
||||
pickupCode: string;
|
||||
syncFileListToReceiver: (fileInfos: FileInfo[], reason: string) => void;
|
||||
isPeerConnected: boolean;
|
||||
}
|
||||
|
||||
export const useFileStateManager = ({
|
||||
mode,
|
||||
pickupCode,
|
||||
syncFileListToReceiver,
|
||||
isPeerConnected
|
||||
}: UseFileStateManagerProps) => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [fileList, setFileList] = useState<FileInfo[]>([]);
|
||||
const [downloadedFiles, setDownloadedFiles] = useState<Map<string, File>>(new Map());
|
||||
|
||||
// 生成文件ID
|
||||
const generateFileId = useCallback(() => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}, []);
|
||||
|
||||
// 文件选择处理
|
||||
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(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready',
|
||||
progress: 0
|
||||
}));
|
||||
|
||||
setFileList(prev => {
|
||||
const updatedList = [...prev, ...newFileInfos];
|
||||
console.log('更新后的文件列表:', updatedList);
|
||||
return updatedList;
|
||||
});
|
||||
}, [generateFileId]);
|
||||
|
||||
// 清空文件
|
||||
const clearFiles = useCallback(() => {
|
||||
console.log('=== 清空文件 ===');
|
||||
setSelectedFiles([]);
|
||||
setFileList([]);
|
||||
}, []);
|
||||
|
||||
// 重置状态
|
||||
const resetFiles = useCallback(() => {
|
||||
console.log('=== 重置文件状态 ===');
|
||||
setSelectedFiles([]);
|
||||
setFileList([]);
|
||||
setDownloadedFiles(new Map());
|
||||
}, []);
|
||||
|
||||
// 更新文件状态
|
||||
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, transferSpeed?: number) => {
|
||||
const newStatus = progress >= 100 ? 'completed' as const : 'downloading' as const;
|
||||
setFileList(prev => prev.map(item => {
|
||||
if (item.id === fileId || item.name === fileName) {
|
||||
return {
|
||||
...item,
|
||||
progress,
|
||||
status: newStatus,
|
||||
transferSpeed: transferSpeed ?? item.transferSpeed,
|
||||
startTime: newStatus === 'downloading' && !item.startTime ? Date.now() : item.startTime
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 数据通道第一次打开时初始化
|
||||
useEffect(() => {
|
||||
if (isPeerConnected && mode === 'send' && fileList.length > 0) {
|
||||
console.log('P2P连接已建立,数据通道首次打开,初始化文件列表');
|
||||
syncFileListToReceiver(fileList, '数据通道初始化');
|
||||
}
|
||||
}, [isPeerConnected, mode, syncFileListToReceiver]);
|
||||
|
||||
// 监听fileList大小变化并同步
|
||||
useEffect(() => {
|
||||
if (isPeerConnected && mode === 'send' && pickupCode) {
|
||||
console.log('fileList大小变化,同步到接收方:', fileList.length);
|
||||
syncFileListToReceiver(fileList, 'fileList大小变化');
|
||||
}
|
||||
}, [fileList.length, isPeerConnected, mode, pickupCode, syncFileListToReceiver]);
|
||||
|
||||
// 监听selectedFiles变化,同步更新fileList
|
||||
useEffect(() => {
|
||||
// 只有在发送模式下且已有房间时才处理文件列表同步
|
||||
if (mode !== 'send' || !pickupCode) return;
|
||||
|
||||
console.log('=== selectedFiles变化,同步文件列表 ===', {
|
||||
selectedFilesCount: selectedFiles.length,
|
||||
selectedFileNames: selectedFiles.map(f => f.name)
|
||||
});
|
||||
|
||||
// 使用函数式更新获取当前fileList,避免依赖fileList
|
||||
setFileList(currentFileList => {
|
||||
// 根据selectedFiles创建新的文件信息列表
|
||||
const newFileInfos: FileInfo[] = selectedFiles.map(file => {
|
||||
// 尝试在当前fileList中找到现有的文件信息,保持已有的状态
|
||||
const existingFileInfo = currentFileList.find(info => info.name === file.name && info.size === file.size);
|
||||
return existingFileInfo || {
|
||||
id: generateFileId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
status: 'ready' as const,
|
||||
progress: 0
|
||||
};
|
||||
});
|
||||
|
||||
// 检查文件列表是否真正发生变化
|
||||
const fileListChanged =
|
||||
newFileInfos.length !== currentFileList.length ||
|
||||
newFileInfos.some(newFile =>
|
||||
!currentFileList.find(oldFile => oldFile.name === newFile.name && oldFile.size === newFile.size)
|
||||
);
|
||||
|
||||
if (fileListChanged) {
|
||||
console.log('文件列表发生变化,更新:', {
|
||||
before: currentFileList.map(f => f.name),
|
||||
after: newFileInfos.map(f => f.name)
|
||||
});
|
||||
|
||||
return newFileInfos;
|
||||
}
|
||||
|
||||
// 如果没有变化,返回当前的fileList
|
||||
return currentFileList;
|
||||
});
|
||||
}, [selectedFiles, mode, pickupCode, generateFileId]); // 移除fileList依赖,避免无限循环
|
||||
|
||||
// 清除发送方数据(当接收方离开房间时)
|
||||
const clearSenderData = useCallback(() => {
|
||||
console.log('[FileStateManager] 接收方离开房间,清除发送方数据');
|
||||
// 只清除文件列表和传输状态,不清除选中的文件
|
||||
// 这样用户可以重新连接后继续发送
|
||||
setFileList(prev => prev.map(file => ({
|
||||
...file,
|
||||
status: 'ready' as const,
|
||||
progress: 0,
|
||||
transferSpeed: undefined,
|
||||
startTime: undefined
|
||||
})));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
fileList,
|
||||
setFileList,
|
||||
downloadedFiles,
|
||||
setDownloadedFiles,
|
||||
handleFileSelect,
|
||||
clearFiles,
|
||||
resetFiles,
|
||||
updateFileStatus,
|
||||
updateFileProgress,
|
||||
clearSenderData
|
||||
};
|
||||
};
|
||||
755
chuan-next/src/hooks/file-transfer/useFileTransferBusiness.ts
Normal file
755
chuan-next/src/hooks/file-transfer/useFileTransferBusiness.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type IWebConnection } from '../connection/types';
|
||||
|
||||
|
||||
// 文件传输状态
|
||||
interface FileTransferState {
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
connectionError: string | null;
|
||||
isTransferring: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
receivedFiles: Array<{ id: string; file: File }>;
|
||||
}
|
||||
|
||||
// 单个文件的接收进度
|
||||
interface FileReceiveProgress {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
totalChunks: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// 文件信息
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// 文件元数据
|
||||
interface FileMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// 文件块信息
|
||||
interface FileChunk {
|
||||
fileId: string;
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
checksum?: string; // 数据校验和
|
||||
}
|
||||
|
||||
// 块确认信息
|
||||
interface ChunkAck {
|
||||
fileId: string;
|
||||
chunkIndex: number;
|
||||
success: boolean;
|
||||
checksum?: string;
|
||||
}
|
||||
|
||||
// 传输状态
|
||||
interface TransferStatus {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
totalChunks: number;
|
||||
sentChunks: Set<number>;
|
||||
acknowledgedChunks: Set<number>;
|
||||
failedChunks: Set<number>;
|
||||
lastChunkTime: number;
|
||||
retryCount: Map<number, number>;
|
||||
averageSpeed: number; // KB/s
|
||||
}
|
||||
|
||||
// 回调类型
|
||||
type FileReceivedCallback = (fileData: { id: string; file: File }) => void;
|
||||
type FileRequestedCallback = (fileId: string, fileName: string) => void;
|
||||
type FileProgressCallback = (progressInfo: { fileId: string; fileName: string; progress: number }) => void;
|
||||
type FileListReceivedCallback = (fileList: FileInfo[]) => void;
|
||||
|
||||
const CHANNEL_NAME = 'file-transfer';
|
||||
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校验和
|
||||
*/
|
||||
function calculateChecksum(data: ArrayBuffer): string {
|
||||
const buffer = new Uint8Array(data);
|
||||
let crc = 0xFFFFFFFF;
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
crc ^= buffer[i];
|
||||
for (let j = 0; j < 8; j++) {
|
||||
crc = crc & 1 ? (crc >>> 1) ^ 0xEDB88320 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
|
||||
return (crc ^ 0xFFFFFFFF).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 文件传输业务层
|
||||
* 必须传入共享的 WebRTC 连接
|
||||
*/
|
||||
export function useFileTransferBusiness(connection: IWebConnection) {
|
||||
|
||||
const [state, setState] = useState<FileTransferState>({
|
||||
isConnecting: false,
|
||||
isConnected: false,
|
||||
isWebSocketConnected: false,
|
||||
connectionError: null,
|
||||
isTransferring: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
receivedFiles: [],
|
||||
});
|
||||
|
||||
// 接收文件缓存
|
||||
const receivingFiles = useRef<Map<string, {
|
||||
metadata: FileMetadata;
|
||||
chunks: ArrayBuffer[];
|
||||
receivedChunks: number;
|
||||
}>>(new Map());
|
||||
|
||||
// 当前期望的文件块
|
||||
const expectedChunk = useRef<FileChunk | null>(null);
|
||||
|
||||
// 回调存储
|
||||
const fileReceivedCallbacks = useRef<Set<FileReceivedCallback>>(new Set());
|
||||
const fileRequestedCallbacks = useRef<Set<FileRequestedCallback>>(new Set());
|
||||
const fileProgressCallbacks = useRef<Set<FileProgressCallback>>(new Set());
|
||||
const fileListCallbacks = useRef<Set<FileListReceivedCallback>>(new Set());
|
||||
|
||||
// 传输状态管理
|
||||
const transferStatus = useRef<Map<string, TransferStatus>>(new Map());
|
||||
const pendingChunks = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const chunkAckCallbacks = useRef<Map<string, Set<(ack: ChunkAck) => void>>>(new Map());
|
||||
|
||||
// 接收文件进度跟踪
|
||||
const receiveProgress = useRef<Map<string, FileReceiveProgress>>(new Map());
|
||||
const activeReceiveFile = useRef<string | null>(null);
|
||||
|
||||
const updateState = useCallback((updates: Partial<FileTransferState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 消息处理器
|
||||
const handleMessage = useCallback((message: any) => {
|
||||
if (!message.type.startsWith('file-')) return;
|
||||
|
||||
console.log('文件传输收到消息:', message.type, message); switch (message.type) {
|
||||
case 'file-metadata':
|
||||
const metadata: FileMetadata = message.payload;
|
||||
console.log('开始接收文件:', metadata.name);
|
||||
|
||||
receivingFiles.current.set(metadata.id, {
|
||||
metadata,
|
||||
chunks: [],
|
||||
receivedChunks: 0,
|
||||
});
|
||||
|
||||
// 初始化接收进度跟踪
|
||||
const totalChunks = Math.ceil(metadata.size / getChunkSize(connection.connectType));
|
||||
receiveProgress.current.set(metadata.id, {
|
||||
fileId: metadata.id,
|
||||
fileName: metadata.name,
|
||||
totalChunks,
|
||||
progress: 0
|
||||
});
|
||||
|
||||
// 设置当前活跃的接收文件
|
||||
activeReceiveFile.current = metadata.id;
|
||||
updateState({ isTransferring: true, progress: 0 });
|
||||
break;
|
||||
|
||||
case 'file-chunk-info':
|
||||
expectedChunk.current = message.payload;
|
||||
console.log('准备接收文件块:', message.payload);
|
||||
break;
|
||||
|
||||
case 'file-complete':
|
||||
const { fileId } = message.payload;
|
||||
const fileInfo = receivingFiles.current.get(fileId);
|
||||
|
||||
if (fileInfo) {
|
||||
// 组装文件
|
||||
const blob = new Blob(fileInfo.chunks, { type: fileInfo.metadata.type });
|
||||
const file = new File([blob], fileInfo.metadata.name, {
|
||||
type: fileInfo.metadata.type
|
||||
});
|
||||
|
||||
console.log('文件接收完成:', file.name);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
receivedFiles: [...prev.receivedFiles, { id: fileId, file }],
|
||||
isTransferring: false,
|
||||
progress: 100
|
||||
}));
|
||||
|
||||
fileReceivedCallbacks.current.forEach(cb => cb({ id: fileId, file }));
|
||||
receivingFiles.current.delete(fileId);
|
||||
receiveProgress.current.delete(fileId);
|
||||
|
||||
// 清除活跃文件
|
||||
if (activeReceiveFile.current === fileId) {
|
||||
activeReceiveFile.current = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-list':
|
||||
console.log('收到文件列表:', message.payload);
|
||||
fileListCallbacks.current.forEach(cb => cb(message.payload));
|
||||
break;
|
||||
|
||||
case 'file-request':
|
||||
const { fileId: requestedFileId, fileName } = message.payload;
|
||||
console.log('收到文件请求:', fileName, requestedFileId);
|
||||
fileRequestedCallbacks.current.forEach(cb => cb(requestedFileId, fileName));
|
||||
break;
|
||||
|
||||
case 'file-chunk-ack':
|
||||
const ack: ChunkAck = message.payload;
|
||||
console.log('收到块确认:', ack);
|
||||
|
||||
// 清除超时定时器
|
||||
const chunkKey = `${ack.fileId}-${ack.chunkIndex}`;
|
||||
const timeout = pendingChunks.current.get(chunkKey);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
pendingChunks.current.delete(chunkKey);
|
||||
}
|
||||
|
||||
// 调用确认回调
|
||||
const callbacks = chunkAckCallbacks.current.get(chunkKey);
|
||||
if (callbacks) {
|
||||
callbacks.forEach(cb => cb(ack));
|
||||
chunkAckCallbacks.current.delete(chunkKey);
|
||||
}
|
||||
|
||||
// 更新传输状态
|
||||
const status = transferStatus.current.get(ack.fileId);
|
||||
if (status) {
|
||||
if (ack.success) {
|
||||
status.acknowledgedChunks.add(ack.chunkIndex);
|
||||
status.failedChunks.delete(ack.chunkIndex);
|
||||
} else {
|
||||
status.failedChunks.add(ack.chunkIndex);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 处理文件块数据
|
||||
const handleData = useCallback((data: ArrayBuffer) => {
|
||||
if (!expectedChunk.current) {
|
||||
console.warn('收到数据但没有对应的块信息');
|
||||
return;
|
||||
}
|
||||
|
||||
const { fileId, chunkIndex, totalChunks, checksum: expectedChecksum } = expectedChunk.current;
|
||||
const fileInfo = receivingFiles.current.get(fileId);
|
||||
|
||||
if (fileInfo) {
|
||||
// 验证数据完整性
|
||||
const actualChecksum = calculateChecksum(data);
|
||||
const isValid = !expectedChecksum || actualChecksum === expectedChecksum;
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`文件块校验失败: 期望 ${expectedChecksum}, 实际 ${actualChecksum}`);
|
||||
|
||||
// 发送失败确认
|
||||
connection.sendMessage({
|
||||
type: 'file-chunk-ack',
|
||||
payload: {
|
||||
fileId,
|
||||
chunkIndex,
|
||||
success: false,
|
||||
checksum: actualChecksum
|
||||
}
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
expectedChunk.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经接收过这个块,避免重复计数
|
||||
const alreadyReceived = fileInfo.chunks[chunkIndex] !== undefined;
|
||||
|
||||
// 数据有效,保存到缓存
|
||||
fileInfo.chunks[chunkIndex] = data;
|
||||
|
||||
// 只有在首次接收时才增加计数
|
||||
if (!alreadyReceived) {
|
||||
fileInfo.receivedChunks++;
|
||||
}
|
||||
|
||||
// 更新接收进度跟踪 - 使用 fileInfo 的计数,避免双重计数
|
||||
const progressInfo = receiveProgress.current.get(fileId);
|
||||
if (progressInfo) {
|
||||
progressInfo.progress = progressInfo.totalChunks > 0 ?
|
||||
(fileInfo.receivedChunks / progressInfo.totalChunks) * 100 : 0;
|
||||
|
||||
// 只有当这个文件是当前活跃文件时才更新全局进度
|
||||
if (activeReceiveFile.current === fileId) {
|
||||
updateState({ progress: progressInfo.progress });
|
||||
}
|
||||
|
||||
// 触发进度回调
|
||||
fileProgressCallbacks.current.forEach(cb => cb({
|
||||
fileId: fileId,
|
||||
fileName: progressInfo.fileName,
|
||||
progress: progressInfo.progress
|
||||
}));
|
||||
|
||||
console.log(`文件 ${progressInfo.fileName} 接收进度: ${progressInfo.progress.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
// 发送成功确认
|
||||
connection.sendMessage({
|
||||
type: 'file-chunk-ack',
|
||||
payload: {
|
||||
fileId,
|
||||
chunkIndex,
|
||||
success: true,
|
||||
checksum: actualChecksum
|
||||
}
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
expectedChunk.current = null;
|
||||
}
|
||||
}, [updateState, connection]);
|
||||
|
||||
const connectionRef = useRef(connection);
|
||||
|
||||
useEffect(() => {
|
||||
connectionRef.current = connection;
|
||||
}, [connection]);
|
||||
|
||||
useEffect(() => {
|
||||
// 使用共享连接的注册方式
|
||||
const unregisterMessage = connectionRef.current.registerMessageHandler(CHANNEL_NAME, handleMessage);
|
||||
const unregisterData = connectionRef.current.registerDataHandler(CHANNEL_NAME, handleData);
|
||||
|
||||
return () => {
|
||||
unregisterMessage();
|
||||
unregisterData();
|
||||
};
|
||||
}, []); // 只依赖 connection 对象,不依赖处理函数
|
||||
|
||||
// 监听连接状态变化 (直接使用 connection 的状态)
|
||||
useEffect(() => {
|
||||
// 同步连接状态
|
||||
updateState({
|
||||
isConnecting: connection.getConnectState().isConnecting,
|
||||
isConnected: connection.getConnectState().isConnected,
|
||||
isWebSocketConnected: connection.getConnectState().isWebSocketConnected,
|
||||
connectionError: connection.getConnectState().error
|
||||
});
|
||||
}, [connection.getConnectState, updateState]);
|
||||
|
||||
// 连接
|
||||
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
return connection.connect(roomCode, role);
|
||||
}, [connection]);
|
||||
|
||||
// 安全发送单个文件块
|
||||
const sendChunkWithAck = useCallback(async (
|
||||
fileId: string,
|
||||
chunkIndex: number,
|
||||
chunkData: ArrayBuffer,
|
||||
checksum: string,
|
||||
retryCount = 0
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
// 主要检查数据通道状态,因为数据通道是文件传输的实际通道
|
||||
const channelState = connection.getConnectState();
|
||||
if (channelState.state === 'closed') {
|
||||
console.warn(`数据通道已关闭,停止发送文件块 ${chunkIndex}`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
|
||||
if (!channelState.isConnected && channelState.state === 'connecting') {
|
||||
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
|
||||
}
|
||||
|
||||
const chunkKey = `${fileId}-${chunkIndex}`;
|
||||
|
||||
// 设置确认回调
|
||||
const ackCallback = (ack: ChunkAck) => {
|
||||
if (ack.success) {
|
||||
resolve(true);
|
||||
} else {
|
||||
console.warn(`文件块 ${chunkIndex} 确认失败,准备重试`);
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 注册确认回调
|
||||
if (!chunkAckCallbacks.current.has(chunkKey)) {
|
||||
chunkAckCallbacks.current.set(chunkKey, new Set());
|
||||
}
|
||||
chunkAckCallbacks.current.get(chunkKey)!.add(ackCallback);
|
||||
|
||||
// 设置超时定时器
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn(`文件块 ${chunkIndex} 确认超时`);
|
||||
chunkAckCallbacks.current.get(chunkKey)?.delete(ackCallback);
|
||||
resolve(false);
|
||||
}, ACK_TIMEOUT);
|
||||
|
||||
pendingChunks.current.set(chunkKey, timeout);
|
||||
|
||||
// 发送块信息
|
||||
connection.sendMessage({
|
||||
type: 'file-chunk-info',
|
||||
payload: {
|
||||
fileId,
|
||||
chunkIndex,
|
||||
totalChunks: 0, // 这里不需要,因为已经在元数据中发送
|
||||
checksum
|
||||
}
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
// 发送块数据
|
||||
connection.sendData(chunkData);
|
||||
});
|
||||
}, [connection]);
|
||||
|
||||
// 安全发送文件
|
||||
const sendFileSecure = useCallback(async (file: File, fileId?: string) => {
|
||||
if (connection.getConnectState().state !== 'open') {
|
||||
updateState({ error: '连接未就绪' });
|
||||
return;
|
||||
}
|
||||
|
||||
const actualFileId = fileId || `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const chunkSize = getChunkSize(connection.connectType);
|
||||
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||
|
||||
console.log('开始安全发送文件:', file.name, '文件ID:', actualFileId, '总块数:', totalChunks, '块大小:', chunkSize);
|
||||
|
||||
updateState({ isTransferring: true, progress: 0, error: null });
|
||||
|
||||
// 初始化传输状态
|
||||
const status: TransferStatus = {
|
||||
fileId: actualFileId,
|
||||
fileName: file.name,
|
||||
totalChunks,
|
||||
sentChunks: new Set(),
|
||||
acknowledgedChunks: new Set(),
|
||||
failedChunks: new Set(),
|
||||
lastChunkTime: Date.now(),
|
||||
retryCount: new Map(),
|
||||
averageSpeed: 0
|
||||
};
|
||||
transferStatus.current.set(actualFileId, status);
|
||||
|
||||
try {
|
||||
// 1. 发送文件元数据
|
||||
connection.sendMessage({
|
||||
type: 'file-metadata',
|
||||
payload: {
|
||||
id: actualFileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
// 2. 分块发送文件
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
let success = false;
|
||||
let retryCount = 0;
|
||||
|
||||
while (!success && retryCount <= MAX_RETRIES) {
|
||||
// 检查数据通道状态,这是文件传输的实际通道
|
||||
const channelState = connection.getConnectState();
|
||||
if (channelState.state === 'closed') {
|
||||
console.warn(`数据通道已关闭,停止文件传输`);
|
||||
throw new Error('数据通道已关闭');
|
||||
}
|
||||
|
||||
// 如果连接暂时断开但数据通道可用,仍然可以尝试发送
|
||||
if (!connection.getConnectState().isConnected && channelState.state === 'connecting') {
|
||||
console.warn(`WebRTC 连接暂时断开,但数据通道正在连接,继续尝试发送文件块 ${chunkIndex}`);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
console.log(`发送文件块 ${chunkIndex}/${totalChunks}, 重试次数: ${retryCount}`);
|
||||
|
||||
// 发送块并等待确认
|
||||
success = await sendChunkWithAck(actualFileId, chunkIndex, arrayBuffer, checksum, retryCount);
|
||||
|
||||
if (success) {
|
||||
status.sentChunks.add(chunkIndex);
|
||||
status.acknowledgedChunks.add(chunkIndex);
|
||||
status.failedChunks.delete(chunkIndex);
|
||||
|
||||
// 计算传输速度
|
||||
const now = Date.now();
|
||||
const timeDiff = (now - status.lastChunkTime) / 1000; // 秒
|
||||
if (timeDiff > 0) {
|
||||
const speed = (arrayBuffer.byteLength / 1024) / timeDiff; // KB/s
|
||||
status.averageSpeed = status.averageSpeed * 0.7 + speed * 0.3; // 平滑平均
|
||||
}
|
||||
status.lastChunkTime = now;
|
||||
} else {
|
||||
retryCount++;
|
||||
status.retryCount.set(chunkIndex, retryCount);
|
||||
|
||||
if (retryCount > MAX_RETRIES) {
|
||||
status.failedChunks.add(chunkIndex);
|
||||
throw new Error(`文件块 ${chunkIndex} 发送失败,超过最大重试次数`);
|
||||
}
|
||||
|
||||
// 指数退避
|
||||
const delay = Math.min(RETRY_DELAY * Math.pow(2, retryCount - 1), 10000);
|
||||
console.log(`等待 ${delay}ms 后重试文件块 ${chunkIndex}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新进度 - 基于已发送的块数,这样与接收方的进度更同步
|
||||
const progress = ((chunkIndex + 1) / totalChunks) * 100;
|
||||
updateState({ progress });
|
||||
|
||||
fileProgressCallbacks.current.forEach(cb => cb({
|
||||
fileId: actualFileId,
|
||||
fileName: file.name,
|
||||
progress
|
||||
}));
|
||||
|
||||
// 自适应流控:根据传输速度调整发送间隔
|
||||
if (status.averageSpeed > 0) {
|
||||
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);
|
||||
|
||||
if (delay > 10) {
|
||||
await new Promise(resolve => setTimeout(resolve, Math.min(delay, 100)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 验证所有块都已确认
|
||||
if (status.acknowledgedChunks.size !== totalChunks) {
|
||||
throw new Error(`文件传输不完整:${status.acknowledgedChunks.size}/${totalChunks} 块已确认`);
|
||||
}
|
||||
|
||||
// 4. 发送完成信号
|
||||
connection.sendMessage({
|
||||
type: 'file-complete',
|
||||
payload: { fileId: actualFileId }
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
updateState({ isTransferring: false, progress: 100 });
|
||||
console.log('文件安全发送完成:', file.name, `平均速度: ${status.averageSpeed.toFixed(2)} KB/s`);
|
||||
transferStatus.current.delete(actualFileId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('安全发送文件失败:', error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '发送失败',
|
||||
isTransferring: false
|
||||
});
|
||||
transferStatus.current.delete(actualFileId);
|
||||
}
|
||||
}, [connection, updateState, sendChunkWithAck]);
|
||||
|
||||
// 保持原有的 sendFile 方法用于向后兼容
|
||||
const sendFile = useCallback(async (file: File, fileId?: string) => {
|
||||
// 默认使用新的安全发送方法
|
||||
return sendFileSecure(file, fileId);
|
||||
}, [sendFileSecure]);
|
||||
|
||||
// 发送文件列表
|
||||
const sendFileList = useCallback((fileList: FileInfo[]) => {
|
||||
// 检查连接状态 - 优先检查数据通道状态,因为 P2P 连接可能已经建立但状态未及时更新
|
||||
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
|
||||
});
|
||||
|
||||
// 使用更宽松的条件检查连接状态
|
||||
const isReadyToSend = channelReadyState === 'open' ||
|
||||
dataChannelConnected ||
|
||||
peerConnected ||
|
||||
channelState.isConnected;
|
||||
|
||||
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('连接未就绪,等待连接后再发送文件列表:', {
|
||||
channelReadyState,
|
||||
dataChannelConnected,
|
||||
peerConnected,
|
||||
isConnected: channelState.isConnected
|
||||
});
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
// 请求文件
|
||||
const requestFile = useCallback((fileId: string, fileName: string) => {
|
||||
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);
|
||||
|
||||
const sendResult = connection.sendMessage({
|
||||
type: 'file-request',
|
||||
payload: { fileId, fileName }
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
if (!sendResult) {
|
||||
console.error('文件请求发送失败,可能是数据通道问题');
|
||||
// 不立即重试,让上层逻辑处理重试
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
// 注册回调函数
|
||||
const onFileReceived = useCallback((callback: FileReceivedCallback) => {
|
||||
fileReceivedCallbacks.current.add(callback);
|
||||
return () => { fileReceivedCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
const onFileRequested = useCallback((callback: FileRequestedCallback) => {
|
||||
fileRequestedCallbacks.current.add(callback);
|
||||
return () => { fileRequestedCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
const onFileProgress = useCallback((callback: FileProgressCallback) => {
|
||||
fileProgressCallbacks.current.add(callback);
|
||||
return () => { fileProgressCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
const onFileListReceived = useCallback((callback: FileListReceivedCallback) => {
|
||||
fileListCallbacks.current.add(callback);
|
||||
return () => { fileListCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
// 清除发送方数据
|
||||
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,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
disconnect: connection.disconnect,
|
||||
sendFile,
|
||||
sendFileList,
|
||||
requestFile,
|
||||
clearSenderData,
|
||||
|
||||
// 回调注册
|
||||
onFileReceived,
|
||||
onFileRequested,
|
||||
onFileProgress,
|
||||
onFileListReceived,
|
||||
};
|
||||
}
|
||||
17
chuan-next/src/hooks/index.ts
Normal file
17
chuan-next/src/hooks/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// 按功能分类的hooks导出
|
||||
|
||||
// 连接相关
|
||||
export * from './connection';
|
||||
|
||||
// 文件传输相关
|
||||
export * from './file-transfer';
|
||||
|
||||
// 桌面共享相关
|
||||
export * from './desktop-share';
|
||||
|
||||
// 文本传输相关
|
||||
export * from './text-transfer';
|
||||
|
||||
// UI状态管理相关
|
||||
export * from './ui';
|
||||
|
||||
3
chuan-next/src/hooks/settings/index.ts
Normal file
3
chuan-next/src/hooks/settings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './useIceServersConfig';
|
||||
export * from './useWebRTCConfigSync';
|
||||
export * from './useWebRTCConfigSync';
|
||||
251
chuan-next/src/hooks/settings/useIceServersConfig.ts
Normal file
251
chuan-next/src/hooks/settings/useIceServersConfig.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface IceServerConfig {
|
||||
id: string;
|
||||
urls: string;
|
||||
username?: string;
|
||||
credential?: string;
|
||||
type: 'stun' | 'turn';
|
||||
enabled: boolean;
|
||||
isDefault?: boolean; // 标记是否为默认服务器
|
||||
}
|
||||
|
||||
const DEFAULT_ICE_SERVERS: IceServerConfig[] = [
|
||||
{
|
||||
id: 'easyvoip-stun',
|
||||
urls: 'stun:stun.easyvoip.com:3478',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'miwifi-stun',
|
||||
urls: 'stun:stun.miwifi.com:3478',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'google-stun-1',
|
||||
urls: 'stun:stun.l.google.com:19302',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'google-stun-2',
|
||||
urls: 'stun:stun1.l.google.com:19302',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'twilio-stun',
|
||||
urls: 'stun:global.stun.twilio.com:3478',
|
||||
type: 'stun',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'webrtc-ice-servers-config-090901';
|
||||
|
||||
export function useIceServersConfig() {
|
||||
const [iceServers, setIceServers] = useState<IceServerConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = useCallback(() => {
|
||||
try {
|
||||
const savedConfig = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedConfig) {
|
||||
const parsed = JSON.parse(savedConfig);
|
||||
// 确保所有服务器都有isDefault属性
|
||||
const serversWithDefaults = parsed.map((server: any) => ({
|
||||
...server,
|
||||
isDefault: server.isDefault !== undefined ? server.isDefault :
|
||||
DEFAULT_ICE_SERVERS.some(defaultServer => defaultServer.id === server.id)
|
||||
}));
|
||||
setIceServers(serversWithDefaults);
|
||||
} else {
|
||||
setIceServers(DEFAULT_ICE_SERVERS);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载ICE服务器配置失败:', error);
|
||||
setIceServers(DEFAULT_ICE_SERVERS);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = useCallback((servers: IceServerConfig[]) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(servers));
|
||||
setIceServers(servers);
|
||||
} catch (error) {
|
||||
console.error('保存ICE服务器配置失败:', error);
|
||||
throw new Error('保存配置失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 添加服务器
|
||||
const addIceServer = useCallback((config: Omit<IceServerConfig, 'id'>) => {
|
||||
const newServer: IceServerConfig = {
|
||||
...config,
|
||||
id: `custom-${Date.now()}`,
|
||||
isDefault: false, // 用户添加的服务器不标记为默认
|
||||
};
|
||||
const updatedServers = [...iceServers, newServer];
|
||||
saveConfig(updatedServers);
|
||||
}, [iceServers, saveConfig]);
|
||||
|
||||
// 更新服务器
|
||||
const updateIceServer = useCallback((id: string, updates: Partial<IceServerConfig>) => {
|
||||
const updatedServers = iceServers.map(server =>
|
||||
server.id === id ? { ...server, ...updates } : server
|
||||
);
|
||||
saveConfig(updatedServers);
|
||||
}, [iceServers, saveConfig]);
|
||||
|
||||
// 删除服务器
|
||||
const removeIceServer = useCallback((id: string) => {
|
||||
// 确保至少保留一个服务器
|
||||
if (iceServers.length <= 1) {
|
||||
throw new Error('至少需要保留一个ICE服务器');
|
||||
}
|
||||
|
||||
const updatedServers = iceServers.filter(server => server.id !== id);
|
||||
saveConfig(updatedServers);
|
||||
}, [iceServers, saveConfig]);
|
||||
|
||||
// 恢复默认配置
|
||||
const resetToDefault = useCallback(() => {
|
||||
saveConfig(DEFAULT_ICE_SERVERS);
|
||||
}, [saveConfig]);
|
||||
|
||||
// 获取WebRTC格式的配置
|
||||
const getWebRTCConfig = useCallback((): RTCIceServer[] => {
|
||||
return iceServers
|
||||
.filter(server => server.enabled)
|
||||
.map(server => {
|
||||
const rtcServer: RTCIceServer = {
|
||||
urls: server.urls,
|
||||
};
|
||||
|
||||
if (server.username) {
|
||||
rtcServer.username = server.username;
|
||||
}
|
||||
|
||||
if (server.credential) {
|
||||
rtcServer.credential = server.credential;
|
||||
}
|
||||
|
||||
return rtcServer;
|
||||
});
|
||||
}, [iceServers]);
|
||||
|
||||
// 验证服务器配置
|
||||
const validateServer = useCallback((config: Omit<IceServerConfig, 'id'>) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!config.urls.trim()) {
|
||||
errors.push('服务器地址不能为空');
|
||||
} else {
|
||||
// 基本URL格式验证
|
||||
const urlPattern = /^(stun|turn|turns):.+/i;
|
||||
if (!urlPattern.test(config.urls)) {
|
||||
errors.push('服务器地址格式不正确(应以 stun: 或 turn: 开头)');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'turn') {
|
||||
if (!config.username?.trim()) {
|
||||
errors.push('TURN服务器需要用户名');
|
||||
}
|
||||
if (!config.credential?.trim()) {
|
||||
errors.push('TURN服务器需要密码');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}, []);
|
||||
|
||||
// 初始化加载
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
return {
|
||||
iceServers,
|
||||
isLoading,
|
||||
addIceServer,
|
||||
updateIceServer,
|
||||
removeIceServer,
|
||||
resetToDefault,
|
||||
getWebRTCConfig,
|
||||
validateServer,
|
||||
saveConfig,
|
||||
};
|
||||
}
|
||||
|
||||
// 独立的函数,用于在非React组件中获取ICE服务器配置
|
||||
export function getIceServersConfig(): RTCIceServer[] {
|
||||
if (typeof window === 'undefined') {
|
||||
// 服务器端默认配置
|
||||
return [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) {
|
||||
// 返回默认配置的WebRTC格式
|
||||
return DEFAULT_ICE_SERVERS
|
||||
.filter(server => server.enabled)
|
||||
.map(server => {
|
||||
const rtcServer: RTCIceServer = {
|
||||
urls: server.urls,
|
||||
};
|
||||
|
||||
if (server.username) {
|
||||
rtcServer.username = server.username;
|
||||
}
|
||||
|
||||
if (server.credential) {
|
||||
rtcServer.credential = server.credential;
|
||||
}
|
||||
|
||||
return rtcServer;
|
||||
});
|
||||
}
|
||||
|
||||
const iceServers: IceServerConfig[] = JSON.parse(saved);
|
||||
return iceServers
|
||||
.filter(server => server.enabled)
|
||||
.map(server => {
|
||||
const rtcServer: RTCIceServer = {
|
||||
urls: server.urls,
|
||||
};
|
||||
|
||||
if (server.username) {
|
||||
rtcServer.username = server.username;
|
||||
}
|
||||
|
||||
if (server.credential) {
|
||||
rtcServer.credential = server.credential;
|
||||
}
|
||||
|
||||
return rtcServer;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取ICE服务器配置失败:', error);
|
||||
// 发生错误时返回默认配置
|
||||
return [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
];
|
||||
}
|
||||
}
|
||||
29
chuan-next/src/hooks/settings/useWebRTCConfigSync.ts
Normal file
29
chuan-next/src/hooks/settings/useWebRTCConfigSync.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
|
||||
export function useWebRTCConfigSync() {
|
||||
const { showToast } = useToast();
|
||||
|
||||
// 监听存储变化事件
|
||||
const handleStorageChange = useCallback((event: StorageEvent) => {
|
||||
if (event.key === 'webrtc-ice-servers-config') {
|
||||
showToast(
|
||||
'检测到WebRTC配置更改,请重新建立连接以应用新配置',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听localStorage变化
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, [handleStorageChange]);
|
||||
|
||||
return {
|
||||
// 可以在这里添加其他配置同步相关的方法
|
||||
};
|
||||
}
|
||||
2
chuan-next/src/hooks/text-transfer/index.ts
Normal file
2
chuan-next/src/hooks/text-transfer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// 文本传输相关的hooks
|
||||
export { useTextTransferBusiness } from './useTextTransferBusiness';
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { WebRTCConnection } from './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,
|
||||
|
||||
6
chuan-next/src/hooks/ui/index.ts
Normal file
6
chuan-next/src/hooks/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// UI状态管理相关的hooks
|
||||
export { useWebRTCStore } from '../connection/state/webConnectStore';
|
||||
export { useTabNavigation } from './useTabNavigation';
|
||||
export type { TabType } from './useTabNavigation';
|
||||
export { useURLHandler } from './useURLHandler';
|
||||
|
||||
52
chuan-next/src/hooks/ui/useConfirmDialog.ts
Normal file
52
chuan-next/src/hooks/ui/useConfirmDialog.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface ConfirmDialogOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
type?: 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
export interface ConfirmDialogState extends ConfirmDialogOptions {
|
||||
isOpen: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const useConfirmDialog = () => {
|
||||
const [dialogState, setDialogState] = useState<ConfirmDialogState | null>(null);
|
||||
|
||||
const showConfirmDialog = useCallback((options: ConfirmDialogOptions): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const handleConfirm = () => {
|
||||
setDialogState(null);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setDialogState(null);
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
setDialogState({
|
||||
...options,
|
||||
isOpen: true,
|
||||
onConfirm: handleConfirm,
|
||||
onCancel: handleCancel,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
if (dialogState) {
|
||||
dialogState.onCancel();
|
||||
}
|
||||
}, [dialogState]);
|
||||
|
||||
return {
|
||||
dialogState,
|
||||
showConfirmDialog,
|
||||
closeDialog,
|
||||
};
|
||||
};
|
||||
183
chuan-next/src/hooks/ui/useTabNavigation.ts
Normal file
183
chuan-next/src/hooks/ui/useTabNavigation.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useConnectManager } from '../connection';
|
||||
import { useWebRTCStore } from '../connection/state/webConnectStore';
|
||||
import { useConfirmDialog } from './useConfirmDialog';
|
||||
import { FeatureType, useURLHandler } from './useURLHandler';
|
||||
|
||||
// Tab类型定义(包括非WebRTC功能)
|
||||
export type TabType = 'webrtc' | 'message' | 'desktop' | 'wechat' | 'settings';
|
||||
|
||||
// Tab显示名称
|
||||
const TAB_NAMES: Record<TabType, string> = {
|
||||
webrtc: '文件传输',
|
||||
message: '文字传输',
|
||||
desktop: '桌面共享',
|
||||
wechat: '微信群',
|
||||
settings: '设置'
|
||||
};
|
||||
|
||||
// WebRTC功能的映射
|
||||
const WEBRTC_FEATURES: Record<string, FeatureType> = {
|
||||
webrtc: 'webrtc',
|
||||
message: 'message',
|
||||
desktop: 'desktop'
|
||||
};
|
||||
|
||||
export const useTabNavigation = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('webrtc');
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
const { showConfirmDialog, dialogState, closeDialog } = useConfirmDialog();
|
||||
|
||||
// 获取WebRTC全局状态
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isPeerConnected,
|
||||
currentRoom,
|
||||
} = useWebRTCStore();
|
||||
|
||||
// 获取WebRTC连接管理器
|
||||
const { disconnect: disconnectWebRTC } = useConnectManager();
|
||||
|
||||
// 创建一个通用的URL处理器(用于断开连接)
|
||||
const { hasActiveConnection } = useURLHandler({
|
||||
featureType: 'webrtc', // 默认值,实际使用时会被覆盖
|
||||
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标签页');
|
||||
setActiveTab('message');
|
||||
} else if (urlType === 'webrtc') {
|
||||
console.log('检测到webrtc类型,切换到webrtc标签页(文件传输)');
|
||||
setActiveTab('webrtc');
|
||||
} else if (urlType && ['message', 'desktop'].includes(urlType)) {
|
||||
console.log('切换到对应标签页:', urlType);
|
||||
setActiveTab(urlType as TabType);
|
||||
} else {
|
||||
console.log('没有有效的type参数,使用默认标签页:webrtc(文件传输)');
|
||||
// 保持默认的webrtc标签
|
||||
}
|
||||
|
||||
setHasInitialized(true);
|
||||
}
|
||||
}, [searchParams, hasInitialized]);
|
||||
|
||||
// 处理tab切换
|
||||
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];
|
||||
const targetTabName = TAB_NAMES[newTab];
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: '切换功能确认',
|
||||
message: `切换到${targetTabName}需要断开当前的${currentTabName}连接,是否继续?`,
|
||||
confirmText: '确认切换',
|
||||
cancelText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 断开连接并清除状态
|
||||
disconnectWebRTC();
|
||||
console.log(`已清除WebRTC连接状态,切换到${targetTabName}`);
|
||||
|
||||
setActiveTab(newTab);
|
||||
// 清除URL参数
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.search = '';
|
||||
window.history.pushState({}, '', newUrl.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果切换到非活跃连接的wechat或settings tab,直接切换
|
||||
if (newTab === 'wechat' || newTab === 'settings') {
|
||||
setActiveTab(newTab);
|
||||
// 清除URL参数
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.search = '';
|
||||
window.history.pushState({}, '', newUrl.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果有活跃连接且切换到不同的WebRTC功能,需要确认
|
||||
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}连接,是否继续?`,
|
||||
confirmText: '确认切换',
|
||||
cancelText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 用户确认后,重置WebRTC状态
|
||||
disconnectWebRTC();
|
||||
console.log(`已断开${currentTabName}连接,切换到${targetTabName}`);
|
||||
}
|
||||
|
||||
// 执行tab切换
|
||||
setActiveTab(newTab);
|
||||
|
||||
// 更新URL(对于WebRTC功能)
|
||||
if (WEBRTC_FEATURES[newTab]) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('type', WEBRTC_FEATURES[newTab]);
|
||||
params.set('mode', 'send'); // 默认模式
|
||||
const newUrl = `?${params.toString()}`;
|
||||
window.history.pushState({}, '', newUrl);
|
||||
} else {
|
||||
// 非WebRTC功能,清除URL参数
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.search = '';
|
||||
window.history.pushState({}, '', newUrl.toString());
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [activeTab, hasActiveConnection, disconnectWebRTC]);
|
||||
|
||||
// 获取连接状态信息
|
||||
const getConnectionInfo = useCallback(() => {
|
||||
return {
|
||||
hasConnection: hasActiveConnection(),
|
||||
currentRoom: currentRoom,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isPeerConnected
|
||||
};
|
||||
}, [hasActiveConnection, currentRoom, isConnected, isConnecting, isPeerConnected]);
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
handleTabChange,
|
||||
getConnectionInfo,
|
||||
hasInitialized,
|
||||
// 导出确认对话框状态
|
||||
confirmDialogState: dialogState,
|
||||
closeConfirmDialog: closeDialog
|
||||
};
|
||||
};
|
||||
210
chuan-next/src/hooks/ui/useURLHandler.ts
Normal file
210
chuan-next/src/hooks/ui/useURLHandler.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useToast } from '@/components/ui/toast-simple';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useWebRTCStore } from '../connection/state/webConnectStore';
|
||||
import { useConfirmDialog } from './useConfirmDialog';
|
||||
|
||||
// 支持的功能类型
|
||||
export type FeatureType = 'webrtc' | 'message' | 'desktop';
|
||||
|
||||
// 功能类型的显示名称
|
||||
const FEATURE_NAMES: Record<FeatureType, string> = {
|
||||
webrtc: '文件传输',
|
||||
message: '文字传输',
|
||||
desktop: '桌面共享'
|
||||
};
|
||||
|
||||
interface UseURLHandlerProps<T = string> {
|
||||
featureType: FeatureType;
|
||||
onModeChange: (mode: T) => void;
|
||||
onAutoJoinRoom?: (code: string) => void;
|
||||
onDisconnect?: () => void; // 新增:断开连接的回调
|
||||
modeConverter?: {
|
||||
// 将URL模式转换为组件内部模式
|
||||
fromURL: (urlMode: 'send' | 'receive') => T;
|
||||
// 将组件内部模式转换为URL模式
|
||||
toURL: (componentMode: T) => 'send' | 'receive';
|
||||
};
|
||||
}
|
||||
|
||||
export const useURLHandler = <T = 'send' | 'receive'>({
|
||||
featureType,
|
||||
onModeChange,
|
||||
onAutoJoinRoom,
|
||||
onDisconnect,
|
||||
modeConverter
|
||||
}: UseURLHandlerProps<T>) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const { showConfirmDialog, dialogState, closeDialog } = useConfirmDialog();
|
||||
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
|
||||
const urlProcessedRef = useRef(false);
|
||||
|
||||
// 获取WebRTC全局状态
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isPeerConnected,
|
||||
currentRoom,
|
||||
reset: resetWebRTCState
|
||||
} = useWebRTCStore();
|
||||
|
||||
// 检查是否有活跃连接
|
||||
const hasActiveConnection = useCallback(() => {
|
||||
return isConnected || isConnecting || isPeerConnected;
|
||||
}, [isConnected, isConnecting, isPeerConnected]);
|
||||
|
||||
// 功能切换确认
|
||||
const switchToFeature = useCallback(async (targetFeatureType: FeatureType, mode?: 'send' | 'receive', code?: string) => {
|
||||
// 如果是同一个功能,直接切换
|
||||
if (targetFeatureType === featureType) {
|
||||
if (mode) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', targetFeatureType);
|
||||
params.set('mode', mode);
|
||||
if (code) {
|
||||
params.set('code', code);
|
||||
} else if (mode === 'send') {
|
||||
params.delete('code');
|
||||
}
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果有活跃连接,需要确认
|
||||
if (hasActiveConnection()) {
|
||||
const currentFeatureName = FEATURE_NAMES[featureType];
|
||||
const targetFeatureName = FEATURE_NAMES[targetFeatureType];
|
||||
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: '切换功能确认',
|
||||
message: `切换到${targetFeatureName}功能需要关闭当前的${currentFeatureName}连接,是否继续?`,
|
||||
confirmText: '确认切换',
|
||||
cancelText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 用户确认后,断开当前连接
|
||||
try {
|
||||
if (onDisconnect) {
|
||||
await onDisconnect();
|
||||
}
|
||||
resetWebRTCState();
|
||||
showToast(`已断开${currentFeatureName}连接`, 'info');
|
||||
} catch (error) {
|
||||
console.error('断开连接时出错:', error);
|
||||
showToast('断开连接时出错,但将继续切换功能', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到新功能
|
||||
const params = new URLSearchParams();
|
||||
params.set('type', targetFeatureType);
|
||||
if (mode) {
|
||||
params.set('mode', mode);
|
||||
}
|
||||
if (code) {
|
||||
params.set('code', code);
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
return true;
|
||||
}, [featureType, hasActiveConnection, onDisconnect, resetWebRTCState, showToast, router, searchParams, showConfirmDialog]);
|
||||
|
||||
// 从URL参数中获取初始模式(仅在首次加载时处理)
|
||||
useEffect(() => {
|
||||
// 使用 ref 确保只处理一次,避免严格模式的重复调用
|
||||
if (urlProcessedRef.current) {
|
||||
console.log('URL已处理过,跳过重复处理');
|
||||
return;
|
||||
}
|
||||
|
||||
const urlMode = searchParams.get('mode') as 'send' | 'receive';
|
||||
const type = searchParams.get('type') as FeatureType;
|
||||
const code = searchParams.get('code');
|
||||
|
||||
// 只在首次加载且URL中有对应功能类型时处理
|
||||
if (!hasProcessedInitialUrl && type === featureType && urlMode && ['send', 'receive'].includes(urlMode)) {
|
||||
console.log(`=== 处理初始URL参数 [${featureType}] ===`);
|
||||
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
|
||||
|
||||
// 立即标记为已处理,防止重复
|
||||
urlProcessedRef.current = true;
|
||||
|
||||
// 转换模式(如果有转换器的话)
|
||||
const componentMode = modeConverter ? modeConverter.fromURL(urlMode) : urlMode as T;
|
||||
onModeChange(componentMode);
|
||||
setHasProcessedInitialUrl(true);
|
||||
|
||||
// 自动加入房间(只在receive模式且有code时)
|
||||
if (code && urlMode === 'receive' && onAutoJoinRoom) {
|
||||
console.log('URL中有取件码,自动加入房间');
|
||||
onAutoJoinRoom(code);
|
||||
}
|
||||
}
|
||||
}, [searchParams, hasProcessedInitialUrl, featureType, onModeChange, onAutoJoinRoom, modeConverter]);
|
||||
|
||||
// 更新URL参数
|
||||
const updateMode = useCallback((newMode: T) => {
|
||||
console.log(`=== 手动切换模式 [${featureType}] ===`);
|
||||
console.log('新模式:', newMode);
|
||||
|
||||
onModeChange(newMode);
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', featureType);
|
||||
|
||||
// 转换模式(如果有转换器的话)
|
||||
const urlMode = modeConverter ? modeConverter.toURL(newMode) : newMode as string;
|
||||
params.set('mode', urlMode);
|
||||
|
||||
// 如果切换到发送模式,移除code参数
|
||||
if (urlMode === 'send') {
|
||||
params.delete('code');
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router, featureType, onModeChange, modeConverter]);
|
||||
|
||||
// 更新URL中的房间代码
|
||||
const updateRoomCode = useCallback((code: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('type', featureType);
|
||||
params.set('code', code);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, router, featureType]);
|
||||
|
||||
// 获取当前URL中的房间代码
|
||||
const getCurrentRoomCode = useCallback(() => {
|
||||
return searchParams.get('code') || '';
|
||||
}, [searchParams]);
|
||||
|
||||
// 清除URL参数
|
||||
const clearURLParams = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('type');
|
||||
params.delete('mode');
|
||||
params.delete('code');
|
||||
|
||||
const newURL = params.toString() ? `?${params.toString()}` : '/';
|
||||
router.push(newURL, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
return {
|
||||
updateMode,
|
||||
updateRoomCode,
|
||||
getCurrentRoomCode,
|
||||
clearURLParams,
|
||||
switchToFeature,
|
||||
hasActiveConnection,
|
||||
// 导出对话框状态供组件使用
|
||||
confirmDialogState: dialogState,
|
||||
closeConfirmDialog: closeDialog
|
||||
};
|
||||
};
|
||||
@@ -1,407 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useSharedWebRTCManager } from './useSharedWebRTCManager';
|
||||
|
||||
interface DesktopShareState {
|
||||
isSharing: boolean;
|
||||
isViewing: boolean;
|
||||
connectionCode: string;
|
||||
remoteStream: MediaStream | null;
|
||||
error: string | null;
|
||||
isWaitingForPeer: boolean; // 新增:是否等待对方连接
|
||||
}
|
||||
|
||||
export function useDesktopShareBusiness() {
|
||||
const webRTC = useSharedWebRTCManager();
|
||||
const [state, setState] = useState<DesktopShareState>({
|
||||
isSharing: false,
|
||||
isViewing: false,
|
||||
connectionCode: '',
|
||||
remoteStream: null,
|
||||
error: null,
|
||||
isWaitingForPeer: false,
|
||||
});
|
||||
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const currentSenderRef = useRef<RTCRtpSender | null>(null);
|
||||
|
||||
const updateState = useCallback((updates: Partial<DesktopShareState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 生成6位房间代码
|
||||
const generateRoomCode = useCallback(() => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
// 获取桌面共享流
|
||||
const getDesktopStream = useCallback(async (): Promise<MediaStream> => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
cursor: 'always',
|
||||
displaySurface: 'monitor',
|
||||
} as DisplayMediaStreamOptions['video'],
|
||||
audio: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
} as DisplayMediaStreamOptions['audio'],
|
||||
});
|
||||
|
||||
console.log('[DesktopShare] 获取桌面流成功:', stream.getTracks().length, '个轨道');
|
||||
return stream;
|
||||
} catch (error) {
|
||||
console.error('[DesktopShare] 获取桌面流失败:', error);
|
||||
throw new Error('无法获取桌面共享权限,请确保允许屏幕共享');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 设置视频轨道发送
|
||||
const setupVideoSending = useCallback(async (stream: MediaStream) => {
|
||||
console.log('[DesktopShare] 🎬 开始设置视频轨道发送...');
|
||||
|
||||
// 移除之前的轨道(如果存在)
|
||||
if (currentSenderRef.current) {
|
||||
console.log('[DesktopShare] 🗑️ 移除之前的视频轨道');
|
||||
webRTC.removeTrack(currentSenderRef.current);
|
||||
currentSenderRef.current = null;
|
||||
}
|
||||
|
||||
// 添加新的视频轨道到PeerConnection
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
|
||||
if (videoTrack) {
|
||||
console.log('[DesktopShare] 📹 添加视频轨道:', videoTrack.id, videoTrack.readyState);
|
||||
const videoSender = webRTC.addTrack(videoTrack, stream);
|
||||
if (videoSender) {
|
||||
currentSenderRef.current = videoSender;
|
||||
console.log('[DesktopShare] ✅ 视频轨道添加成功');
|
||||
} else {
|
||||
console.warn('[DesktopShare] ⚠️ 视频轨道添加返回null');
|
||||
}
|
||||
} else {
|
||||
console.error('[DesktopShare] ❌ 未找到视频轨道');
|
||||
throw new Error('未找到视频轨道');
|
||||
}
|
||||
|
||||
if (audioTrack) {
|
||||
try {
|
||||
console.log('[DesktopShare] 🎵 添加音频轨道:', audioTrack.id, audioTrack.readyState);
|
||||
const audioSender = webRTC.addTrack(audioTrack, stream);
|
||||
if (audioSender) {
|
||||
console.log('[DesktopShare] ✅ 音频轨道添加成功');
|
||||
} else {
|
||||
console.warn('[DesktopShare] ⚠️ 音频轨道添加返回null');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[DesktopShare] ⚠️ 音频轨道添加失败,继续视频共享:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('[DesktopShare] ℹ️ 未检测到音频轨道(这通常是正常的)');
|
||||
}
|
||||
|
||||
// 轨道添加完成,现在需要重新协商以包含媒体轨道
|
||||
console.log('[DesktopShare] ✅ 桌面共享轨道添加完成,开始重新协商');
|
||||
|
||||
// 检查P2P连接是否已建立
|
||||
if (!webRTC.isPeerConnected) {
|
||||
console.error('[DesktopShare] ❌ P2P连接尚未建立,无法开始媒体传输');
|
||||
throw new Error('P2P连接尚未建立');
|
||||
}
|
||||
|
||||
// 创建新的offer包含媒体轨道
|
||||
console.log('[DesktopShare] 📨 创建包含媒体轨道的新offer进行重新协商');
|
||||
const success = await webRTC.createOfferNow();
|
||||
if (success) {
|
||||
console.log('[DesktopShare] ✅ 媒体轨道重新协商成功');
|
||||
} else {
|
||||
console.error('[DesktopShare] ❌ 媒体轨道重新协商失败');
|
||||
throw new Error('媒体轨道重新协商失败');
|
||||
}
|
||||
|
||||
// 监听流结束事件(用户停止共享)
|
||||
const handleStreamEnded = () => {
|
||||
console.log('[DesktopShare] 🛑 用户停止了屏幕共享');
|
||||
stopSharing();
|
||||
};
|
||||
|
||||
videoTrack?.addEventListener('ended', handleStreamEnded);
|
||||
audioTrack?.addEventListener('ended', handleStreamEnded);
|
||||
|
||||
return () => {
|
||||
videoTrack?.removeEventListener('ended', handleStreamEnded);
|
||||
audioTrack?.removeEventListener('ended', handleStreamEnded);
|
||||
};
|
||||
}, [webRTC]);
|
||||
|
||||
// 处理远程流
|
||||
const handleRemoteStream = useCallback((stream: MediaStream) => {
|
||||
console.log('[DesktopShare] 收到远程流:', stream.getTracks().length, '个轨道');
|
||||
updateState({ remoteStream: stream });
|
||||
|
||||
// 如果有视频元素引用,设置流
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = stream;
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 创建房间(只建立连接,等待对方加入)
|
||||
const createRoom = useCallback(async (): Promise<string> => {
|
||||
try {
|
||||
updateState({ error: null, isWaitingForPeer: false });
|
||||
|
||||
// 生成房间代码
|
||||
const roomCode = generateRoomCode();
|
||||
console.log('[DesktopShare] 🚀 创建桌面共享房间,代码:', roomCode);
|
||||
|
||||
// 建立WebRTC连接(作为发送方)
|
||||
console.log('[DesktopShare] 📡 正在建立WebRTC连接...');
|
||||
await webRTC.connect(roomCode, 'sender');
|
||||
console.log('[DesktopShare] ✅ WebSocket连接已建立');
|
||||
|
||||
updateState({
|
||||
connectionCode: roomCode,
|
||||
isWaitingForPeer: true, // 标记为等待对方连接
|
||||
});
|
||||
|
||||
console.log('[DesktopShare] 🎯 房间创建完成,等待对方加入建立P2P连接');
|
||||
return roomCode;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '创建房间失败';
|
||||
console.error('[DesktopShare] ❌ 创建房间失败:', error);
|
||||
updateState({ error: errorMessage, connectionCode: '', isWaitingForPeer: false });
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, generateRoomCode, updateState]);
|
||||
|
||||
// 开始桌面共享(在接收方加入后)
|
||||
const startSharing = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
// 检查WebSocket连接状态
|
||||
if (!webRTC.isWebSocketConnected) {
|
||||
throw new Error('WebSocket连接未建立,请先创建房间');
|
||||
}
|
||||
|
||||
updateState({ error: null });
|
||||
console.log('[DesktopShare] 📺 正在请求桌面共享权限...');
|
||||
|
||||
// 获取桌面流
|
||||
const stream = await getDesktopStream();
|
||||
localStreamRef.current = stream;
|
||||
console.log('[DesktopShare] ✅ 桌面流获取成功');
|
||||
|
||||
// 设置视频发送(这会添加轨道并创建offer,启动P2P连接)
|
||||
console.log('[DesktopShare] 📤 正在设置视频轨道推送并建立P2P连接...');
|
||||
await setupVideoSending(stream);
|
||||
console.log('[DesktopShare] ✅ 视频轨道推送设置完成');
|
||||
|
||||
updateState({
|
||||
isSharing: true,
|
||||
isWaitingForPeer: false,
|
||||
});
|
||||
|
||||
console.log('[DesktopShare] 🎉 桌面共享已开始');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '开始桌面共享失败';
|
||||
console.error('[DesktopShare] ❌ 开始共享失败:', error);
|
||||
updateState({ error: errorMessage, isSharing: false });
|
||||
|
||||
// 清理资源
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
localStreamRef.current = null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, getDesktopStream, setupVideoSending, updateState]);
|
||||
|
||||
// 切换桌面共享(重新选择屏幕)
|
||||
const switchDesktop = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
if (!webRTC.isPeerConnected) {
|
||||
throw new Error('P2P连接未建立');
|
||||
}
|
||||
|
||||
if (!state.isSharing) {
|
||||
throw new Error('当前未在共享桌面');
|
||||
}
|
||||
|
||||
updateState({ error: null });
|
||||
console.log('[DesktopShare] 🔄 正在切换桌面共享...');
|
||||
|
||||
// 获取新的桌面流
|
||||
const newStream = await getDesktopStream();
|
||||
|
||||
// 停止之前的流
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
localStreamRef.current = newStream;
|
||||
console.log('[DesktopShare] ✅ 新桌面流获取成功');
|
||||
|
||||
// 设置新的视频发送
|
||||
await setupVideoSending(newStream);
|
||||
console.log('[DesktopShare] ✅ 桌面切换完成');
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '切换桌面失败';
|
||||
console.error('[DesktopShare] ❌ 切换桌面失败:', error);
|
||||
updateState({ error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, state.isSharing, getDesktopStream, setupVideoSending, updateState]);
|
||||
|
||||
// 停止桌面共享
|
||||
const stopSharing = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
console.log('[DesktopShare] 停止桌面共享');
|
||||
|
||||
// 停止本地流
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => {
|
||||
track.stop();
|
||||
console.log('[DesktopShare] 停止轨道:', track.kind);
|
||||
});
|
||||
localStreamRef.current = null;
|
||||
}
|
||||
|
||||
// 移除发送器
|
||||
if (currentSenderRef.current) {
|
||||
webRTC.removeTrack(currentSenderRef.current);
|
||||
currentSenderRef.current = null;
|
||||
}
|
||||
|
||||
// 断开WebRTC连接
|
||||
webRTC.disconnect();
|
||||
|
||||
updateState({
|
||||
isSharing: false,
|
||||
connectionCode: '',
|
||||
error: null,
|
||||
isWaitingForPeer: false,
|
||||
});
|
||||
|
||||
console.log('[DesktopShare] 桌面共享已停止');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '停止桌面共享失败';
|
||||
console.error('[DesktopShare] 停止共享失败:', error);
|
||||
updateState({ error: errorMessage });
|
||||
}
|
||||
}, [webRTC, updateState]);
|
||||
|
||||
// 加入桌面共享观看
|
||||
const joinSharing = useCallback(async (code: string): Promise<void> => {
|
||||
try {
|
||||
updateState({ error: null });
|
||||
console.log('[DesktopShare] 🔍 正在加入桌面共享观看:', code);
|
||||
|
||||
// 连接WebRTC
|
||||
console.log('[DesktopShare] 🔗 正在连接WebRTC作为接收方...');
|
||||
await webRTC.connect(code, 'receiver');
|
||||
console.log('[DesktopShare] ✅ WebRTC连接建立完成');
|
||||
|
||||
// 等待连接完全建立
|
||||
console.log('[DesktopShare] ⏳ 等待连接稳定...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 设置远程流处理 - 在连接建立后设置
|
||||
console.log('[DesktopShare] 📡 设置远程流处理器...');
|
||||
webRTC.onTrack((event: RTCTrackEvent) => {
|
||||
console.log('[DesktopShare] 🎥 收到远程轨道:', event.track.kind, event.track.id);
|
||||
console.log('[DesktopShare] 远程流数量:', event.streams.length);
|
||||
|
||||
if (event.streams.length > 0) {
|
||||
const remoteStream = event.streams[0];
|
||||
console.log('[DesktopShare] 🎬 设置远程流,轨道数量:', remoteStream.getTracks().length);
|
||||
handleRemoteStream(remoteStream);
|
||||
} else {
|
||||
console.warn('[DesktopShare] ⚠️ 收到轨道但没有关联的流');
|
||||
}
|
||||
});
|
||||
|
||||
updateState({ isViewing: true });
|
||||
console.log('[DesktopShare] 👁️ 已进入桌面共享观看模式,等待接收流...');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '加入桌面共享失败';
|
||||
console.error('[DesktopShare] ❌ 加入观看失败:', error);
|
||||
updateState({ error: errorMessage, isViewing: false });
|
||||
throw error;
|
||||
}
|
||||
}, [webRTC, handleRemoteStream, updateState]);
|
||||
|
||||
// 停止观看桌面共享
|
||||
const stopViewing = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
console.log('[DesktopShare] 停止观看桌面共享');
|
||||
|
||||
// 断开WebRTC连接
|
||||
webRTC.disconnect();
|
||||
|
||||
updateState({
|
||||
isViewing: false,
|
||||
remoteStream: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
console.log('[DesktopShare] 已停止观看桌面共享');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '停止观看失败';
|
||||
console.error('[DesktopShare] 停止观看失败:', error);
|
||||
updateState({ error: errorMessage });
|
||||
}
|
||||
}, [webRTC, updateState]);
|
||||
|
||||
// 设置远程视频元素引用
|
||||
const setRemoteVideoRef = useCallback((videoElement: HTMLVideoElement | null) => {
|
||||
remoteVideoRef.current = videoElement;
|
||||
if (videoElement && state.remoteStream) {
|
||||
videoElement.srcObject = state.remoteStream;
|
||||
}
|
||||
}, [state.remoteStream]);
|
||||
|
||||
// 清理资源
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isSharing: state.isSharing,
|
||||
isViewing: state.isViewing,
|
||||
connectionCode: state.connectionCode,
|
||||
remoteStream: state.remoteStream,
|
||||
error: state.error,
|
||||
isWaitingForPeer: state.isWaitingForPeer,
|
||||
isConnected: webRTC.isConnected,
|
||||
isConnecting: webRTC.isConnecting,
|
||||
isWebSocketConnected: webRTC.isWebSocketConnected,
|
||||
isPeerConnected: webRTC.isPeerConnected,
|
||||
// 新增:表示是否可以开始共享(WebSocket已连接且有房间代码)
|
||||
canStartSharing: webRTC.isWebSocketConnected && !!state.connectionCode,
|
||||
|
||||
// 方法
|
||||
createRoom, // 创建房间
|
||||
startSharing, // 选择桌面并建立P2P连接
|
||||
switchDesktop, // 新增:切换桌面
|
||||
stopSharing,
|
||||
joinSharing,
|
||||
stopViewing,
|
||||
setRemoteVideoRef,
|
||||
|
||||
// WebRTC连接状态
|
||||
webRTCError: webRTC.error,
|
||||
};
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { WebRTCConnection } from './useSharedWebRTCManager';
|
||||
|
||||
// 文件传输状态
|
||||
interface FileTransferState {
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
connectionError: string | null;
|
||||
isTransferring: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
receivedFiles: Array<{ id: string; file: File }>;
|
||||
}
|
||||
|
||||
// 文件信息
|
||||
interface FileInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'ready' | 'downloading' | 'completed';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// 文件元数据
|
||||
interface FileMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// 文件块信息
|
||||
interface FileChunk {
|
||||
fileId: string;
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
}
|
||||
|
||||
// 回调类型
|
||||
type FileReceivedCallback = (fileData: { id: string; file: File }) => void;
|
||||
type FileRequestedCallback = (fileId: string, fileName: string) => void;
|
||||
type FileProgressCallback = (progressInfo: { fileId: string; fileName: string; progress: number }) => void;
|
||||
type FileListReceivedCallback = (fileList: FileInfo[]) => void;
|
||||
|
||||
const CHANNEL_NAME = 'file-transfer';
|
||||
const CHUNK_SIZE = 256 * 1024; // 256KB
|
||||
|
||||
/**
|
||||
* 文件传输业务层
|
||||
* 必须传入共享的 WebRTC 连接
|
||||
*/
|
||||
export function useFileTransferBusiness(connection: WebRTCConnection) {
|
||||
|
||||
const [state, setState] = useState<FileTransferState>({
|
||||
isConnecting: false,
|
||||
isConnected: false,
|
||||
isWebSocketConnected: false,
|
||||
connectionError: null,
|
||||
isTransferring: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
receivedFiles: [],
|
||||
});
|
||||
|
||||
// 接收文件缓存
|
||||
const receivingFiles = useRef<Map<string, {
|
||||
metadata: FileMetadata;
|
||||
chunks: ArrayBuffer[];
|
||||
receivedChunks: number;
|
||||
}>>(new Map());
|
||||
|
||||
// 当前期望的文件块
|
||||
const expectedChunk = useRef<FileChunk | null>(null);
|
||||
|
||||
// 回调存储
|
||||
const fileReceivedCallbacks = useRef<Set<FileReceivedCallback>>(new Set());
|
||||
const fileRequestedCallbacks = useRef<Set<FileRequestedCallback>>(new Set());
|
||||
const fileProgressCallbacks = useRef<Set<FileProgressCallback>>(new Set());
|
||||
const fileListCallbacks = useRef<Set<FileListReceivedCallback>>(new Set());
|
||||
|
||||
const updateState = useCallback((updates: Partial<FileTransferState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 消息处理器
|
||||
const handleMessage = useCallback((message: any) => {
|
||||
if (!message.type.startsWith('file-')) return;
|
||||
|
||||
console.log('文件传输收到消息:', message.type, message); switch (message.type) {
|
||||
case 'file-metadata':
|
||||
const metadata: FileMetadata = message.payload;
|
||||
console.log('开始接收文件:', metadata.name);
|
||||
|
||||
receivingFiles.current.set(metadata.id, {
|
||||
metadata,
|
||||
chunks: [],
|
||||
receivedChunks: 0,
|
||||
});
|
||||
|
||||
updateState({ isTransferring: true, progress: 0 });
|
||||
break;
|
||||
|
||||
case 'file-chunk-info':
|
||||
expectedChunk.current = message.payload;
|
||||
console.log('准备接收文件块:', message.payload);
|
||||
break;
|
||||
|
||||
case 'file-complete':
|
||||
const { fileId } = message.payload;
|
||||
const fileInfo = receivingFiles.current.get(fileId);
|
||||
|
||||
if (fileInfo) {
|
||||
// 组装文件
|
||||
const blob = new Blob(fileInfo.chunks, { type: fileInfo.metadata.type });
|
||||
const file = new File([blob], fileInfo.metadata.name, {
|
||||
type: fileInfo.metadata.type
|
||||
});
|
||||
|
||||
console.log('文件接收完成:', file.name);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
receivedFiles: [...prev.receivedFiles, { id: fileId, file }],
|
||||
isTransferring: false,
|
||||
progress: 100
|
||||
}));
|
||||
|
||||
fileReceivedCallbacks.current.forEach(cb => cb({ id: fileId, file }));
|
||||
receivingFiles.current.delete(fileId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-list':
|
||||
console.log('收到文件列表:', message.payload);
|
||||
fileListCallbacks.current.forEach(cb => cb(message.payload));
|
||||
break;
|
||||
|
||||
case 'file-request':
|
||||
const { fileId: requestedFileId, fileName } = message.payload;
|
||||
console.log('收到文件请求:', fileName, requestedFileId);
|
||||
fileRequestedCallbacks.current.forEach(cb => cb(requestedFileId, fileName));
|
||||
break;
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 处理文件块数据
|
||||
const handleData = useCallback((data: ArrayBuffer) => {
|
||||
if (!expectedChunk.current) {
|
||||
console.warn('收到数据但没有对应的块信息');
|
||||
return;
|
||||
}
|
||||
|
||||
const { fileId, chunkIndex, totalChunks } = expectedChunk.current;
|
||||
const fileInfo = receivingFiles.current.get(fileId);
|
||||
|
||||
if (fileInfo) {
|
||||
fileInfo.chunks[chunkIndex] = data;
|
||||
fileInfo.receivedChunks++;
|
||||
|
||||
const progress = (fileInfo.receivedChunks / totalChunks) * 100;
|
||||
updateState({ progress });
|
||||
|
||||
fileProgressCallbacks.current.forEach(cb => cb({
|
||||
fileId: fileId,
|
||||
fileName: fileInfo.metadata.name,
|
||||
progress
|
||||
}));
|
||||
|
||||
console.log(`文件 ${fileInfo.metadata.name} 接收进度: ${progress.toFixed(1)}%`);
|
||||
expectedChunk.current = null;
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 设置处理器
|
||||
useEffect(() => {
|
||||
// 使用共享连接的注册方式
|
||||
const unregisterMessage = connection.registerMessageHandler(CHANNEL_NAME, handleMessage);
|
||||
const unregisterData = connection.registerDataHandler(CHANNEL_NAME, handleData);
|
||||
|
||||
return () => {
|
||||
unregisterMessage();
|
||||
unregisterData();
|
||||
};
|
||||
}, [handleMessage, handleData]);
|
||||
|
||||
// 监听连接状态变化 (直接使用 connection 的状态)
|
||||
useEffect(() => {
|
||||
// 同步连接状态
|
||||
updateState({
|
||||
isConnecting: connection.isConnecting,
|
||||
isConnected: connection.isConnected,
|
||||
isWebSocketConnected: connection.isWebSocketConnected,
|
||||
connectionError: connection.error
|
||||
});
|
||||
}, [connection.isConnecting, connection.isConnected, connection.isWebSocketConnected, connection.error, updateState]);
|
||||
|
||||
// 连接
|
||||
const connect = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
return connection.connect(roomCode, role);
|
||||
}, [connection]);
|
||||
|
||||
// 发送文件
|
||||
const sendFile = useCallback(async (file: File, fileId?: string) => {
|
||||
if (connection.getChannelState() !== 'open') {
|
||||
updateState({ error: '连接未就绪' });
|
||||
return;
|
||||
}
|
||||
|
||||
const actualFileId = fileId || `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
|
||||
console.log('开始发送文件:', file.name, '文件ID:', actualFileId, '总块数:', totalChunks);
|
||||
|
||||
updateState({ isTransferring: true, progress: 0, error: null });
|
||||
|
||||
try {
|
||||
// 1. 发送文件元数据
|
||||
connection.sendMessage({
|
||||
type: 'file-metadata',
|
||||
payload: {
|
||||
id: actualFileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
// 2. 分块发送文件
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
// 先发送块信息
|
||||
connection.sendMessage({
|
||||
type: 'file-chunk-info',
|
||||
payload: {
|
||||
fileId: actualFileId,
|
||||
chunkIndex,
|
||||
totalChunks
|
||||
}
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
// 再发送块数据
|
||||
const arrayBuffer = await chunk.arrayBuffer();
|
||||
connection.sendData(arrayBuffer);
|
||||
|
||||
const progress = ((chunkIndex + 1) / totalChunks) * 100;
|
||||
updateState({ progress });
|
||||
|
||||
fileProgressCallbacks.current.forEach(cb => cb({
|
||||
fileId: actualFileId,
|
||||
fileName: file.name,
|
||||
progress
|
||||
}));
|
||||
|
||||
// 简单的流控:等待一小段时间让接收方处理
|
||||
if (chunkIndex % 10 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 发送完成信号
|
||||
connection.sendMessage({
|
||||
type: 'file-complete',
|
||||
payload: { fileId: actualFileId }
|
||||
}, CHANNEL_NAME);
|
||||
|
||||
updateState({ isTransferring: false, progress: 100 });
|
||||
console.log('文件发送完成:', file.name);
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送文件失败:', error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '发送失败',
|
||||
isTransferring: false
|
||||
});
|
||||
}
|
||||
}, [connection, updateState]);
|
||||
|
||||
// 发送文件列表
|
||||
const sendFileList = useCallback((fileList: FileInfo[]) => {
|
||||
if (!connection.isPeerConnected) {
|
||||
console.log('P2P连接未建立,等待连接后再发送文件列表');
|
||||
return;
|
||||
}
|
||||
|
||||
if (connection.getChannelState() !== 'open') {
|
||||
console.error('数据通道未准备就绪,无法发送文件列表');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('发送文件列表:', fileList);
|
||||
|
||||
connection.sendMessage({
|
||||
type: 'file-list',
|
||||
payload: fileList
|
||||
}, CHANNEL_NAME);
|
||||
}, [connection]);
|
||||
|
||||
// 请求文件
|
||||
const requestFile = useCallback((fileId: string, fileName: string) => {
|
||||
if (connection.getChannelState() !== 'open') {
|
||||
console.error('数据通道未准备就绪,无法请求文件');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('请求文件:', fileName, fileId);
|
||||
|
||||
connection.sendMessage({
|
||||
type: 'file-request',
|
||||
payload: { fileId, fileName }
|
||||
}, CHANNEL_NAME);
|
||||
}, [connection]);
|
||||
|
||||
// 注册回调函数
|
||||
const onFileReceived = useCallback((callback: FileReceivedCallback) => {
|
||||
fileReceivedCallbacks.current.add(callback);
|
||||
return () => { fileReceivedCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
const onFileRequested = useCallback((callback: FileRequestedCallback) => {
|
||||
fileRequestedCallbacks.current.add(callback);
|
||||
return () => { fileRequestedCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
const onFileProgress = useCallback((callback: FileProgressCallback) => {
|
||||
fileProgressCallbacks.current.add(callback);
|
||||
return () => { fileProgressCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
const onFileListReceived = useCallback((callback: FileListReceivedCallback) => {
|
||||
fileListCallbacks.current.add(callback);
|
||||
return () => { fileListCallbacks.current.delete(callback); };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 文件传输状态(包括连接状态)
|
||||
...state,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
disconnect: connection.disconnect,
|
||||
sendFile,
|
||||
sendFileList,
|
||||
requestFile,
|
||||
|
||||
// 回调注册
|
||||
onFileReceived,
|
||||
onFileRequested,
|
||||
onFileProgress,
|
||||
onFileListReceived,
|
||||
};
|
||||
}
|
||||
@@ -1,665 +0,0 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { getWsUrl } from '@/lib/config';
|
||||
|
||||
// 基础连接状态
|
||||
interface WebRTCState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean; // 新增:P2P连接状态
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// 消息类型
|
||||
interface WebRTCMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
// 消息和数据处理器类型
|
||||
type MessageHandler = (message: WebRTCMessage) => void;
|
||||
type DataHandler = (data: ArrayBuffer) => void;
|
||||
|
||||
// WebRTC 连接接口
|
||||
export interface WebRTCConnection {
|
||||
// 状态
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
isWebSocketConnected: boolean;
|
||||
isPeerConnected: boolean; // 新增:P2P连接状态
|
||||
error: string | null;
|
||||
|
||||
// 操作方法
|
||||
connect: (roomCode: string, role: 'sender' | 'receiver') => Promise<void>;
|
||||
disconnect: () => 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 [state, setState] = useState<WebRTCState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const dcRef = useRef<RTCDataChannel | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 当前连接的房间信息
|
||||
const currentRoom = useRef<{ code: string; role: 'sender' | 'receiver' } | null>(null);
|
||||
|
||||
// 多通道消息处理器
|
||||
const messageHandlers = useRef<Map<string, MessageHandler>>(new Map());
|
||||
const dataHandlers = useRef<Map<string, DataHandler>>(new Map());
|
||||
|
||||
// STUN 服务器配置 - 使用更稳定的服务器
|
||||
const STUN_SERVERS = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
{ urls: 'stun:global.stun.twilio.com:3478' },
|
||||
];
|
||||
|
||||
const updateState = useCallback((updates: Partial<WebRTCState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 清理连接
|
||||
const cleanup = useCallback(() => {
|
||||
console.log('[SharedWebRTC] 清理连接');
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (dcRef.current) {
|
||||
dcRef.current.close();
|
||||
dcRef.current = null;
|
||||
}
|
||||
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
currentRoom.current = null;
|
||||
}, []);
|
||||
|
||||
// 创建 Offer
|
||||
const createOffer = useCallback(async (pc: RTCPeerConnection, ws: WebSocket) => {
|
||||
try {
|
||||
console.log('[SharedWebRTC] 🎬 开始创建offer,当前轨道数量:', pc.getSenders().length);
|
||||
|
||||
const offer = await pc.createOffer({
|
||||
offerToReceiveAudio: true, // 改为true以支持音频接收
|
||||
offerToReceiveVideo: true, // 改为true以支持视频接收
|
||||
});
|
||||
|
||||
console.log('[SharedWebRTC] 📝 Offer创建成功,设置本地描述...');
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
const iceTimeout = setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[SharedWebRTC] 📤 发送 offer (超时发送)');
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[SharedWebRTC] 📤 发送 offer (ICE收集完成)');
|
||||
}
|
||||
} else {
|
||||
pc.onicegatheringstatechange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(iceTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'offer', payload: pc.localDescription }));
|
||||
console.log('[SharedWebRTC] 📤 发送 offer (ICE收集完成)');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] ❌ 创建 offer 失败:', error);
|
||||
updateState({ error: '创建连接失败', isConnecting: false });
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
// 处理数据通道消息
|
||||
const handleDataChannelMessage = useCallback((event: MessageEvent) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebRTCMessage;
|
||||
console.log('[SharedWebRTC] 收到消息:', message.type, message.channel || 'default');
|
||||
|
||||
// 根据通道分发消息
|
||||
if (message.channel) {
|
||||
const handler = messageHandlers.current.get(message.channel);
|
||||
if (handler) {
|
||||
handler(message);
|
||||
}
|
||||
} else {
|
||||
// 兼容旧版本,广播给所有处理器
|
||||
messageHandlers.current.forEach(handler => handler(message));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 解析消息失败:', error);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
console.log('[SharedWebRTC] 收到数据:', event.data.byteLength, 'bytes');
|
||||
|
||||
// 数据优先发给文件传输处理器
|
||||
const fileHandler = dataHandlers.current.get('file-transfer');
|
||||
if (fileHandler) {
|
||||
fileHandler(event.data);
|
||||
} else {
|
||||
// 如果没有文件处理器,发给第一个处理器
|
||||
const firstHandler = dataHandlers.current.values().next().value;
|
||||
if (firstHandler) {
|
||||
firstHandler(event.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 连接到房间
|
||||
const connect = useCallback(async (roomCode: string, role: 'sender' | 'receiver') => {
|
||||
console.log('[SharedWebRTC] 🚀 开始连接到房间:', roomCode, role);
|
||||
|
||||
// 如果正在连接中,避免重复连接
|
||||
if (state.isConnecting) {
|
||||
console.warn('[SharedWebRTC] ⚠️ 正在连接中,跳过重复连接请求');
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理之前的连接
|
||||
cleanup();
|
||||
currentRoom.current = { code: roomCode, role };
|
||||
updateState({ isConnecting: true, error: null });
|
||||
|
||||
// 注意:不在这里设置超时,因为WebSocket连接很快,
|
||||
// WebRTC连接的建立是在后续添加轨道时进行的
|
||||
|
||||
try {
|
||||
console.log('[SharedWebRTC] 🔧 创建PeerConnection...');
|
||||
// 创建 PeerConnection
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: STUN_SERVERS,
|
||||
iceCandidatePoolSize: 10,
|
||||
});
|
||||
pcRef.current = pc;
|
||||
|
||||
// 连接 WebSocket - 使用动态URL
|
||||
const baseWsUrl = getWsUrl();
|
||||
if (!baseWsUrl) {
|
||||
throw new Error('WebSocket URL未配置');
|
||||
}
|
||||
|
||||
// 构建完整的WebSocket URL
|
||||
const wsUrl = baseWsUrl.replace('/ws/p2p', `/ws/webrtc?code=${roomCode}&role=${role}&channel=shared`);
|
||||
console.log('[SharedWebRTC] 🌐 连接WebSocket:', wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
// WebSocket 事件处理
|
||||
ws.onopen = () => {
|
||||
console.log('[SharedWebRTC] ✅ WebSocket 连接已建立,房间准备就绪');
|
||||
updateState({
|
||||
isWebSocketConnected: true,
|
||||
isConnecting: false, // WebSocket连接成功即表示初始连接完成
|
||||
isConnected: true // 可以开始后续操作
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('[SharedWebRTC] 📨 收到信令消息:', message.type);
|
||||
|
||||
switch (message.type) {
|
||||
case 'peer-joined':
|
||||
// 对方加入房间的通知
|
||||
console.log('[SharedWebRTC] 👥 对方已加入房间,角色:', message.payload?.role);
|
||||
if (role === 'sender' && message.payload?.role === 'receiver') {
|
||||
console.log('[SharedWebRTC] 🚀 接收方已连接,发送方自动建立P2P连接');
|
||||
updateState({ isPeerConnected: true }); // 标记对方已加入,可以开始P2P
|
||||
|
||||
// 发送方自动创建offer建立基础P2P连接
|
||||
try {
|
||||
console.log('[SharedWebRTC] 📡 自动创建基础P2P连接offer');
|
||||
await createOffer(pc, ws);
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 自动创建基础P2P连接失败:', error);
|
||||
}
|
||||
} else if (role === 'receiver' && message.payload?.role === 'sender') {
|
||||
console.log('[SharedWebRTC] 🚀 发送方已连接,接收方准备接收P2P连接');
|
||||
updateState({ isPeerConnected: true }); // 标记对方已加入
|
||||
}
|
||||
break;
|
||||
|
||||
case 'offer':
|
||||
console.log('[SharedWebRTC] 📬 处理offer...');
|
||||
if (pc.signalingState === 'stable') {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[SharedWebRTC] ✅ 设置远程描述完成');
|
||||
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
console.log('[SharedWebRTC] ✅ 创建并设置answer完成');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'answer', payload: answer }));
|
||||
console.log('[SharedWebRTC] 📤 发送 answer');
|
||||
} else {
|
||||
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是stable:', pc.signalingState);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
console.log('[SharedWebRTC] 📬 处理answer...');
|
||||
if (pc.signalingState === 'have-local-offer') {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(message.payload));
|
||||
console.log('[SharedWebRTC] ✅ answer 处理完成');
|
||||
} else {
|
||||
console.warn('[SharedWebRTC] ⚠️ PeerConnection状态不是have-local-offer:', pc.signalingState);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ice-candidate':
|
||||
if (message.payload && pc.remoteDescription) {
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(message.payload));
|
||||
console.log('[SharedWebRTC] ✅ 添加 ICE 候选成功');
|
||||
} catch (err) {
|
||||
console.warn('[SharedWebRTC] ⚠️ 添加 ICE 候选失败:', err);
|
||||
}
|
||||
} else {
|
||||
console.warn('[SharedWebRTC] ⚠️ ICE候选无效或远程描述未设置');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[SharedWebRTC] ❌ 信令服务器错误:', message.error);
|
||||
updateState({ error: message.error, isConnecting: false });
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[SharedWebRTC] ⚠️ 未知消息类型:', message.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] ❌ 处理信令消息失败:', error);
|
||||
updateState({ error: '信令处理失败: ' + error, isConnecting: false });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[SharedWebRTC] ❌ WebSocket 错误:', error);
|
||||
updateState({ error: 'WebSocket连接失败,请检查服务器是否运行在8080端口', isConnecting: false });
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('[SharedWebRTC] 🔌 WebSocket 连接已关闭, 代码:', event.code, '原因:', event.reason);
|
||||
updateState({ isWebSocketConnected: false });
|
||||
if (event.code !== 1000 && event.code !== 1001) { // 非正常关闭
|
||||
updateState({ error: `WebSocket异常关闭 (${event.code}): ${event.reason || '未知原因'}`, isConnecting: false });
|
||||
}
|
||||
};
|
||||
|
||||
// PeerConnection 事件处理
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ice-candidate',
|
||||
payload: event.candidate
|
||||
}));
|
||||
console.log('[SharedWebRTC] 📤 发送 ICE 候选:', event.candidate.candidate.substring(0, 50) + '...');
|
||||
} else if (!event.candidate) {
|
||||
console.log('[SharedWebRTC] 🏁 ICE 收集完成');
|
||||
}
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
console.log('[SharedWebRTC] 🧊 ICE连接状态变化:', pc.iceConnectionState);
|
||||
switch (pc.iceConnectionState) {
|
||||
case 'checking':
|
||||
console.log('[SharedWebRTC] 🔍 正在检查ICE连接...');
|
||||
break;
|
||||
case 'connected':
|
||||
case 'completed':
|
||||
console.log('[SharedWebRTC] ✅ ICE连接成功');
|
||||
break;
|
||||
case 'failed':
|
||||
console.error('[SharedWebRTC] ❌ ICE连接失败');
|
||||
updateState({ error: 'ICE连接失败,可能是网络防火墙阻止了连接', isConnecting: false });
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('[SharedWebRTC] 🔌 ICE连接断开');
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('[SharedWebRTC] 🚫 ICE连接已关闭');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.log('[SharedWebRTC] 🔗 WebRTC连接状态变化:', pc.connectionState);
|
||||
switch (pc.connectionState) {
|
||||
case 'connecting':
|
||||
console.log('[SharedWebRTC] 🔄 WebRTC正在连接中...');
|
||||
updateState({ isPeerConnected: false });
|
||||
break;
|
||||
case 'connected':
|
||||
console.log('[SharedWebRTC] 🎉 WebRTC P2P连接已完全建立,可以进行媒体传输');
|
||||
updateState({ isPeerConnected: true, error: null });
|
||||
break;
|
||||
case 'failed':
|
||||
// 只有在数据通道也未打开的情况下才认为连接真正失败
|
||||
const currentDc = dcRef.current;
|
||||
if (!currentDc || currentDc.readyState !== 'open') {
|
||||
console.error('[SharedWebRTC] ❌ WebRTC连接失败,数据通道未建立');
|
||||
updateState({ error: 'WebRTC连接失败,请检查网络设置或重试', isPeerConnected: false });
|
||||
} else {
|
||||
console.log('[SharedWebRTC] ⚠️ WebRTC连接状态为failed,但数据通道正常,忽略此状态');
|
||||
}
|
||||
break;
|
||||
case 'disconnected':
|
||||
console.log('[SharedWebRTC] 🔌 WebRTC连接已断开');
|
||||
updateState({ isPeerConnected: false });
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('[SharedWebRTC] 🚫 WebRTC连接已关闭');
|
||||
updateState({ isPeerConnected: false });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 数据通道处理
|
||||
if (role === 'sender') {
|
||||
const dataChannel = pc.createDataChannel('shared-channel', {
|
||||
ordered: true,
|
||||
maxRetransmits: 3
|
||||
});
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('[SharedWebRTC] 数据通道已打开 (发送方)');
|
||||
updateState({ isPeerConnected: true, error: null, isConnecting: false });
|
||||
};
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[SharedWebRTC] 数据通道错误:', error);
|
||||
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
|
||||
};
|
||||
} else {
|
||||
pc.ondatachannel = (event) => {
|
||||
const dataChannel = event.channel;
|
||||
dcRef.current = dataChannel;
|
||||
|
||||
dataChannel.onopen = () => {
|
||||
console.log('[SharedWebRTC] 数据通道已打开 (接收方)');
|
||||
updateState({ isPeerConnected: true, error: null, isConnecting: false });
|
||||
};
|
||||
|
||||
dataChannel.onmessage = handleDataChannelMessage;
|
||||
|
||||
dataChannel.onerror = (error) => {
|
||||
console.error('[SharedWebRTC] 数据通道错误:', error);
|
||||
updateState({ error: '数据通道连接失败,可能是网络环境受限', isConnecting: false });
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// 设置轨道接收处理(对于接收方)
|
||||
pc.ontrack = (event) => {
|
||||
console.log('[SharedWebRTC] 🎥 PeerConnection收到轨道:', event.track.kind, event.track.id);
|
||||
console.log('[SharedWebRTC] 关联的流数量:', event.streams.length);
|
||||
|
||||
if (event.streams.length > 0) {
|
||||
console.log('[SharedWebRTC] 🎬 轨道关联到流:', event.streams[0].id);
|
||||
}
|
||||
|
||||
// 这里不处理,让具体的业务逻辑处理
|
||||
// onTrack会被业务逻辑重新设置
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 连接失败:', error);
|
||||
updateState({
|
||||
error: error instanceof Error ? error.message : '连接失败',
|
||||
isConnecting: false
|
||||
});
|
||||
}
|
||||
}, [updateState, cleanup, createOffer, handleDataChannelMessage, state.isConnecting, state.isConnected]);
|
||||
|
||||
// 断开连接
|
||||
const disconnect = useCallback(() => {
|
||||
console.log('[SharedWebRTC] 断开连接');
|
||||
cleanup();
|
||||
setState({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
isWebSocketConnected: false,
|
||||
isPeerConnected: false,
|
||||
error: null,
|
||||
});
|
||||
}, [cleanup]);
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = useCallback((message: WebRTCMessage, channel?: string) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('[SharedWebRTC] 数据通道未准备就绪');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const messageWithChannel = channel ? { ...message, channel } : message;
|
||||
dataChannel.send(JSON.stringify(messageWithChannel));
|
||||
console.log('[SharedWebRTC] 发送消息:', message.type, channel || 'default');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 发送消息失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 发送二进制数据
|
||||
const sendData = useCallback((data: ArrayBuffer) => {
|
||||
const dataChannel = dcRef.current;
|
||||
if (!dataChannel || dataChannel.readyState !== 'open') {
|
||||
console.error('[SharedWebRTC] 数据通道未准备就绪');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
dataChannel.send(data);
|
||||
console.log('[SharedWebRTC] 发送数据:', data.byteLength, 'bytes');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 发送数据失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 注册消息处理器
|
||||
const registerMessageHandler = useCallback((channel: string, handler: MessageHandler) => {
|
||||
console.log('[SharedWebRTC] 注册消息处理器:', channel);
|
||||
messageHandlers.current.set(channel, handler);
|
||||
|
||||
return () => {
|
||||
console.log('[SharedWebRTC] 取消注册消息处理器:', channel);
|
||||
messageHandlers.current.delete(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 注册数据处理器
|
||||
const registerDataHandler = useCallback((channel: string, handler: DataHandler) => {
|
||||
console.log('[SharedWebRTC] 注册数据处理器:', channel);
|
||||
dataHandlers.current.set(channel, handler);
|
||||
|
||||
return () => {
|
||||
console.log('[SharedWebRTC] 取消注册数据处理器:', channel);
|
||||
dataHandlers.current.delete(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取数据通道状态
|
||||
const getChannelState = useCallback(() => {
|
||||
return dcRef.current?.readyState || 'closed';
|
||||
}, []);
|
||||
|
||||
// 检查是否已连接到指定房间
|
||||
const isConnectedToRoom = useCallback((roomCode: string, role: 'sender' | 'receiver') => {
|
||||
return currentRoom.current?.code === roomCode &&
|
||||
currentRoom.current?.role === role &&
|
||||
state.isConnected;
|
||||
}, [state.isConnected]);
|
||||
|
||||
// 添加媒体轨道
|
||||
const addTrack = useCallback((track: MediaStreamTrack, stream: MediaStream) => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
console.error('[SharedWebRTC] PeerConnection 不可用');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return pc.addTrack(track, stream);
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 添加轨道失败:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 移除媒体轨道
|
||||
const removeTrack = useCallback((sender: RTCRtpSender) => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
console.error('[SharedWebRTC] PeerConnection 不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
pc.removeTrack(sender);
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 移除轨道失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 设置轨道处理器
|
||||
const onTrack = useCallback((handler: (event: RTCTrackEvent) => void) => {
|
||||
const pc = pcRef.current;
|
||||
if (!pc) {
|
||||
console.warn('[SharedWebRTC] PeerConnection 尚未准备就绪,将在连接建立后设置onTrack');
|
||||
// 延迟设置,等待PeerConnection准备就绪
|
||||
const checkAndSetTrackHandler = () => {
|
||||
const currentPc = pcRef.current;
|
||||
if (currentPc) {
|
||||
console.log('[SharedWebRTC] ✅ PeerConnection 已准备就绪,设置onTrack处理器');
|
||||
currentPc.ontrack = handler;
|
||||
} else {
|
||||
console.log('[SharedWebRTC] ⏳ 等待PeerConnection准备就绪...');
|
||||
setTimeout(checkAndSetTrackHandler, 100);
|
||||
}
|
||||
};
|
||||
checkAndSetTrackHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SharedWebRTC] ✅ 立即设置onTrack处理器');
|
||||
pc.ontrack = handler;
|
||||
}, []);
|
||||
|
||||
// 获取PeerConnection实例
|
||||
const getPeerConnection = useCallback(() => {
|
||||
return pcRef.current;
|
||||
}, []);
|
||||
|
||||
// 立即创建offer(用于媒体轨道添加后的重新协商)
|
||||
const createOfferNow = useCallback(async () => {
|
||||
const pc = pcRef.current;
|
||||
const ws = wsRef.current;
|
||||
if (!pc || !ws) {
|
||||
console.error('[SharedWebRTC] PeerConnection 或 WebSocket 不可用');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await createOffer(pc, ws);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[SharedWebRTC] 创建 offer 失败:', error);
|
||||
return false;
|
||||
}
|
||||
}, [createOffer]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isConnected: state.isConnected,
|
||||
isConnecting: state.isConnecting,
|
||||
isWebSocketConnected: state.isWebSocketConnected,
|
||||
isPeerConnected: state.isPeerConnected,
|
||||
error: state.error,
|
||||
|
||||
// 操作方法
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
sendData,
|
||||
|
||||
// 处理器注册
|
||||
registerMessageHandler,
|
||||
registerDataHandler,
|
||||
|
||||
// 工具方法
|
||||
getChannelState,
|
||||
isConnectedToRoom,
|
||||
|
||||
// 媒体轨道方法
|
||||
addTrack,
|
||||
removeTrack,
|
||||
onTrack,
|
||||
getPeerConnection,
|
||||
createOfferNow,
|
||||
|
||||
// 当前房间信息
|
||||
currentRoom: currentRoom.current,
|
||||
};
|
||||
}
|
||||
@@ -11,23 +11,6 @@ interface ApiResponse {
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface CreateRoomData {
|
||||
type?: string;
|
||||
content?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface CreateTextRoomData {
|
||||
type: string;
|
||||
content: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface UpdateFilesData {
|
||||
roomId: string;
|
||||
files: File[];
|
||||
}
|
||||
|
||||
export class ClientAPI {
|
||||
private baseUrl: string;
|
||||
|
||||
@@ -94,20 +77,10 @@ export class ClientAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建房间(统一接口)
|
||||
* 创建房间(简化版本)- 后端会忽略传入的参数
|
||||
*/
|
||||
async createRoom(data: CreateRoomData): Promise<ApiResponse> {
|
||||
return this.post('/api/create-room', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文本房间
|
||||
*/
|
||||
async createTextRoom(content: string): Promise<ApiResponse> {
|
||||
return this.post('/api/create-room', {
|
||||
type: 'text',
|
||||
content: content
|
||||
});
|
||||
async createRoom(): Promise<ApiResponse> {
|
||||
return this.post('/api/create-room', {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,20 +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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件
|
||||
*/
|
||||
async updateFiles(data: UpdateFilesData): Promise<ApiResponse> {
|
||||
return this.post('/api/update-files', data);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
|
||||
@@ -26,19 +26,20 @@ const getCurrentBaseUrl = () => {
|
||||
|
||||
// 动态获取 WebSocket URL - 总是在客户端运行时计算
|
||||
const getCurrentWsUrl = () => {
|
||||
// return "ws://192.168.1.120:8080"
|
||||
if (typeof window !== 'undefined') {
|
||||
// 检查是否是 Next.js 开发服务器(端口 3000 或 3001)
|
||||
const isNextDevServer = window.location.hostname === 'localhost' &&
|
||||
(window.location.port === '3000' || window.location.port === '3001');
|
||||
|
||||
const isNextDevServer = window.location.hostname === 'localhost' &&
|
||||
(window.location.port === '3000' || window.location.port === '3001');
|
||||
|
||||
if (isNextDevServer) {
|
||||
// 开发模式:通过 Next.js 开发服务器访问,连接到后端 WebSocket
|
||||
return 'ws://localhost:8080/ws/p2p';
|
||||
return 'ws://localhost:8080';
|
||||
}
|
||||
|
||||
|
||||
// 生产模式或通过 Go 服务器访问:使用当前域名和端口
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/ws/p2p`;
|
||||
return `${protocol}//${window.location.host}`;
|
||||
}
|
||||
// 服务器端返回空字符串,强制在客户端计算
|
||||
return '';
|
||||
@@ -49,28 +50,28 @@ export const config = {
|
||||
isDev: getEnv('NODE_ENV') === 'development',
|
||||
isProd: getEnv('NODE_ENV') === 'production',
|
||||
isStatic: typeof window !== 'undefined', // 客户端运行时认为是静态模式
|
||||
|
||||
|
||||
// API配置
|
||||
api: {
|
||||
// 后端API地址 (服务器端使用)
|
||||
backendUrl: getEnv('GO_BACKEND_URL', 'http://localhost:8080'),
|
||||
|
||||
|
||||
// 前端API基础URL (客户端使用) - 开发模式下调用 Next.js API 路由
|
||||
baseUrl: getEnv('NEXT_PUBLIC_API_BASE_URL', 'http://localhost:3000'),
|
||||
|
||||
|
||||
// 直接后端URL (客户端在静态模式下使用) - 如果环境变量为空,则使用当前域名
|
||||
directBackendUrl: getEnv('NEXT_PUBLIC_BACKEND_URL') || getCurrentBaseUrl(),
|
||||
|
||||
|
||||
// WebSocket地址 - 在客户端运行时动态计算,不在构建时预设
|
||||
wsUrl: '', // 将通过 getWsUrl() 函数动态获取
|
||||
},
|
||||
|
||||
|
||||
// 超时配置
|
||||
timeout: {
|
||||
api: 30000, // 30秒
|
||||
ws: 60000, // 60秒
|
||||
},
|
||||
|
||||
|
||||
// 重试配置
|
||||
retry: {
|
||||
max: 3,
|
||||
@@ -122,12 +123,12 @@ export function getWsUrl(): string {
|
||||
if (envWsUrl) {
|
||||
return envWsUrl;
|
||||
}
|
||||
|
||||
|
||||
// 如果是服务器端(SSG构建时),返回空字符串
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
// 客户端运行时动态计算
|
||||
return getCurrentWsUrl();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
162
chuan-next/src/lib/webrtc-support.ts
Normal file
162
chuan-next/src/lib/webrtc-support.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* WebRTC 浏览器支持检测工具
|
||||
*/
|
||||
|
||||
export interface WebRTCSupport {
|
||||
isSupported: boolean;
|
||||
missing: string[];
|
||||
details: {
|
||||
rtcPeerConnection: boolean;
|
||||
dataChannel: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测浏览器是否支持WebRTC及其相关功能
|
||||
*/
|
||||
export function detectWebRTCSupport(): WebRTCSupport {
|
||||
const missing: string[] = [];
|
||||
const details = {
|
||||
rtcPeerConnection: false,
|
||||
getUserMedia: false,
|
||||
getDisplayMedia: false,
|
||||
dataChannel: false,
|
||||
};
|
||||
|
||||
// 检测 RTCPeerConnection
|
||||
if (typeof RTCPeerConnection !== 'undefined') {
|
||||
details.rtcPeerConnection = true;
|
||||
} else {
|
||||
missing.push('RTCPeerConnection');
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 检测 DataChannel 支持
|
||||
try {
|
||||
if (typeof RTCPeerConnection !== 'undefined') {
|
||||
const pc = new RTCPeerConnection();
|
||||
const dc = pc.createDataChannel('test');
|
||||
if (dc) {
|
||||
details.dataChannel = true;
|
||||
}
|
||||
pc.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
missing.push('DataChannel');
|
||||
}
|
||||
|
||||
const isSupported = missing.length === 0;
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
missing,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取浏览器信息
|
||||
*/
|
||||
export function getBrowserInfo(): {
|
||||
name: string;
|
||||
version: string;
|
||||
isSupported: boolean;
|
||||
recommendations?: string[];
|
||||
} {
|
||||
const userAgent = navigator.userAgent;
|
||||
let browserName = 'Unknown';
|
||||
let version = 'Unknown';
|
||||
let isSupported = true;
|
||||
const recommendations: string[] = [];
|
||||
|
||||
// Chrome
|
||||
if (/Chrome/.test(userAgent) && !/Edg/.test(userAgent)) {
|
||||
browserName = 'Chrome';
|
||||
const match = userAgent.match(/Chrome\/(\d+)/);
|
||||
version = match ? match[1] : 'Unknown';
|
||||
isSupported = parseInt(version) >= 23;
|
||||
if (!isSupported) {
|
||||
recommendations.push('请升级到 Chrome 23 或更新版本');
|
||||
}
|
||||
}
|
||||
// Firefox
|
||||
else if (/Firefox/.test(userAgent)) {
|
||||
browserName = 'Firefox';
|
||||
const match = userAgent.match(/Firefox\/(\d+)/);
|
||||
version = match ? match[1] : 'Unknown';
|
||||
isSupported = parseInt(version) >= 22;
|
||||
if (!isSupported) {
|
||||
recommendations.push('请升级到 Firefox 22 或更新版本');
|
||||
}
|
||||
}
|
||||
// Safari
|
||||
else if (/Safari/.test(userAgent) && !/Chrome/.test(userAgent)) {
|
||||
browserName = 'Safari';
|
||||
const match = userAgent.match(/Version\/(\d+)/);
|
||||
version = match ? match[1] : 'Unknown';
|
||||
isSupported = parseInt(version) >= 11;
|
||||
if (!isSupported) {
|
||||
recommendations.push('请升级到 Safari 11 或更新版本');
|
||||
}
|
||||
}
|
||||
// Edge
|
||||
else if (/Edg/.test(userAgent)) {
|
||||
browserName = 'Edge';
|
||||
const match = userAgent.match(/Edg\/(\d+)/);
|
||||
version = match ? match[1] : 'Unknown';
|
||||
isSupported = parseInt(version) >= 12;
|
||||
if (!isSupported) {
|
||||
recommendations.push('请升级到 Edge 12 或更新版本');
|
||||
}
|
||||
}
|
||||
// Internet Explorer
|
||||
else if (/MSIE|Trident/.test(userAgent)) {
|
||||
browserName = 'Internet Explorer';
|
||||
isSupported = false;
|
||||
recommendations.push(
|
||||
'请使用现代浏览器,如 Chrome、Firefox、Safari 或 Edge',
|
||||
'Internet Explorer 不支持 WebRTC'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: browserName,
|
||||
version,
|
||||
isSupported,
|
||||
recommendations: recommendations.length > 0 ? recommendations : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推荐的浏览器列表
|
||||
*/
|
||||
export function getRecommendedBrowsers(): Array<{
|
||||
name: string;
|
||||
minVersion: string;
|
||||
downloadUrl: string;
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
name: 'Google Chrome',
|
||||
minVersion: '23+',
|
||||
downloadUrl: 'https://www.google.com/chrome/',
|
||||
},
|
||||
{
|
||||
name: 'Mozilla Firefox',
|
||||
minVersion: '22+',
|
||||
downloadUrl: 'https://www.mozilla.org/firefox/',
|
||||
},
|
||||
{
|
||||
name: 'Safari',
|
||||
minVersion: '11+',
|
||||
downloadUrl: 'https://www.apple.com/safari/',
|
||||
},
|
||||
{
|
||||
name: 'Microsoft Edge',
|
||||
minVersion: '12+',
|
||||
downloadUrl: 'https://www.microsoft.com/edge',
|
||||
},
|
||||
];
|
||||
}
|
||||
74
chuan-next/tailwind.config.js
Normal file
74
chuan-next/tailwind.config.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
borderWidth: {
|
||||
'3': '3px',
|
||||
'4': '4px',
|
||||
},
|
||||
boxShadow: {
|
||||
'cartoon-sm': '2px 2px 0 #000',
|
||||
'cartoon': '4px 4px 0 #000',
|
||||
'cartoon-md': '6px 6px 0 #000',
|
||||
'cartoon-lg': '8px 8px 0 #000',
|
||||
'cartoon-xl': '10px 10px 0 #000',
|
||||
},
|
||||
animation: {
|
||||
'bounce-in': 'bounce-in 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55)',
|
||||
'wiggle': 'wiggle 0.5s ease-in-out infinite',
|
||||
'float-cartoon': 'float-cartoon 4s ease-in-out infinite',
|
||||
'rainbow': 'rainbow 5s ease infinite',
|
||||
'gradient-shift': 'gradientShift 15s ease infinite',
|
||||
},
|
||||
fontFamily: {
|
||||
'cartoon': ['"Comic Sans MS"', '"Chalkboard SE"', '"Comic Neue"', 'cursive'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -3530,3 +3530,8 @@ yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
||||
zustand@^5.0.7:
|
||||
version "5.0.7"
|
||||
resolved "https://registry.npmmirror.com/zustand/-/zustand-5.0.7.tgz#e325364e82c992a84bf386d8445aa7f180c450dc"
|
||||
integrity sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==
|
||||
|
||||
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服务器已禁用")
|
||||
}
|
||||
}
|
||||
102
cmd/main.go
102
cmd/main.go
@@ -1,103 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"chuan/internal/handlers"
|
||||
"chuan/internal/web"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 定义命令行参数
|
||||
var port = flag.Int("port", 8080, "服务器监听端口")
|
||||
var help = flag.Bool("help", false, "显示帮助信息")
|
||||
flag.Parse()
|
||||
|
||||
// 显示帮助信息
|
||||
if *help {
|
||||
fmt.Println("文件传输服务器")
|
||||
fmt.Println("用法:")
|
||||
flag.PrintDefaults()
|
||||
os.Exit(0)
|
||||
// 检查是否需要显示帮助
|
||||
if len(os.Args) > 1 && (os.Args[1] == "-h" || os.Args[1] == "--help") {
|
||||
showHelp()
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化处理器
|
||||
h := handlers.NewHandler()
|
||||
// 加载配置
|
||||
config := loadConfig()
|
||||
|
||||
// 创建路由
|
||||
r := chi.NewRouter()
|
||||
// 记录配置信息
|
||||
logConfig(config)
|
||||
|
||||
// 中间件
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Compress(5))
|
||||
// 设置路由
|
||||
routerSetup := setupRouter(config)
|
||||
|
||||
// CORS 配置
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// 嵌入式前端文件服务
|
||||
r.Handle("/*", web.CreateFrontendHandler())
|
||||
|
||||
// WebRTC信令WebSocket路由
|
||||
r.Get("/ws/webrtc", h.HandleWebRTCWebSocket)
|
||||
|
||||
// WebRTC房间API
|
||||
r.Post("/api/create-room", h.CreateRoomHandler)
|
||||
r.Get("/api/room-info", h.WebRTCRoomStatusHandler)
|
||||
r.Get("/api/webrtc-room-status", h.WebRTCRoomStatusHandler)
|
||||
|
||||
// 构建服务器地址
|
||||
addr := fmt.Sprintf(":%d", *port)
|
||||
|
||||
// 启动服务器
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
go func() {
|
||||
log.Printf("服务器启动在端口 %s", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待中断信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("正在关闭服务器...")
|
||||
|
||||
// 设置关闭超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal("服务器强制关闭:", err)
|
||||
}
|
||||
|
||||
log.Println("服务器已退出")
|
||||
// 运行服务器(包含启动和优雅关闭)
|
||||
RunServer(config, routerSetup)
|
||||
}
|
||||
|
||||
76
cmd/router.go
Normal file
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()
|
||||
}
|
||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# file-transfer-go:
|
||||
# build: .
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
# environment:
|
||||
# - NODE_ENV=production
|
||||
# restart: unless-stopped
|
||||
# healthcheck:
|
||||
# test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080"]
|
||||
# interval: 30s
|
||||
# timeout: 10s
|
||||
# retries: 3
|
||||
# start_period: 40s
|
||||
file-transfer-go:
|
||||
image: matrixseven/file-transfer-go:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
13
go.mod
13
go.mod
@@ -5,6 +5,17 @@ go 1.21
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.0.10
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
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,12 +19,17 @@ 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)
|
||||
}
|
||||
|
||||
// CreateRoomHandler 创建房间API
|
||||
// CreateRoomHandler 创建房间API - 简化版本,不处理无用参数
|
||||
func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 设置响应为JSON格式
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -36,7 +42,7 @@ func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新房间
|
||||
// 创建新房间(忽略请求体中的无用参数)
|
||||
code := h.webrtcService.CreateNewRoom()
|
||||
log.Printf("创建房间成功: %s", code)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebRTCOffer WebRTC offer 结构
|
||||
type WebRTCOffer struct {
|
||||
SDP string `json:"sdp"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// WebRTCAnswer WebRTC answer 结构
|
||||
type WebRTCAnswer struct {
|
||||
SDP string `json:"sdp"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// WebRTCICECandidate ICE candidate 结构
|
||||
type WebRTCICECandidate struct {
|
||||
Candidate string `json:"candidate"`
|
||||
SDPMLineIndex int `json:"sdpMLineIndex"`
|
||||
SDPMid string `json:"sdpMid"`
|
||||
}
|
||||
|
||||
// VideoMessage 视频消息结构
|
||||
type VideoMessage struct {
|
||||
Type string `json:"type"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// ClientInfo 客户端连接信息
|
||||
type ClientInfo struct {
|
||||
ID string `json:"id"` // 客户端唯一标识
|
||||
Role string `json:"role"` // sender 或 receiver
|
||||
Connection *websocket.Conn `json:"-"` // WebSocket连接(不序列化)
|
||||
JoinedAt time.Time `json:"joined_at"` // 加入时间
|
||||
UserAgent string `json:"user_agent"` // 用户代理
|
||||
}
|
||||
|
||||
// RoomStatus 房间状态信息
|
||||
type RoomStatus struct {
|
||||
Code string `json:"code"`
|
||||
SenderOnline bool `json:"sender_online"`
|
||||
ReceiverOnline bool `json:"receiver_online"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ErrorResponse 错误响应结构
|
||||
type ErrorResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
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"
|
||||
@@ -23,7 +24,6 @@ type WebRTCRoom struct {
|
||||
Receiver *WebRTCClient
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time // 添加过期时间
|
||||
LastOffer *WebRTCMessage // 保存最后的offer消息
|
||||
}
|
||||
|
||||
type WebRTCClient struct {
|
||||
@@ -76,6 +76,48 @@ func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
if code == "" || (role != "sender" && role != "receiver") {
|
||||
log.Printf("WebRTC连接参数无效: code=%s, role=%s", code, role)
|
||||
conn.WriteJSON(map[string]interface{}{
|
||||
"type": "error",
|
||||
"message": "连接参数无效",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证房间是否存在
|
||||
ws.roomsMux.RLock()
|
||||
room := ws.rooms[code]
|
||||
ws.roomsMux.RUnlock()
|
||||
|
||||
if room == nil {
|
||||
log.Printf("房间不存在: %s", code)
|
||||
conn.WriteJSON(map[string]interface{}{
|
||||
"type": "error",
|
||||
"message": "房间不存在或已过期",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查房间是否已过期
|
||||
if time.Now().After(room.ExpiresAt) {
|
||||
log.Printf("房间已过期: %s", code)
|
||||
conn.WriteJSON(map[string]interface{}{
|
||||
"type": "error",
|
||||
"message": "房间已过期",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查房间是否已满(两个连接都已存在)
|
||||
ws.roomsMux.RLock()
|
||||
isRoomFull := room.Sender != nil && room.Receiver != nil
|
||||
ws.roomsMux.RUnlock()
|
||||
|
||||
if isRoomFull {
|
||||
log.Printf("房间已满,拒绝连接: %s", code)
|
||||
conn.WriteJSON(map[string]interface{}{
|
||||
"type": "error",
|
||||
"message": "当前房间人数已满,正在传输中无法加入",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,13 +187,8 @@ func (ws *WebRTCService) addClientToRoom(code string, client *WebRTCClient) {
|
||||
|
||||
room := ws.rooms[code]
|
||||
if room == nil {
|
||||
room = &WebRTCRoom{
|
||||
Code: code,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(time.Hour), // 1小时后过期
|
||||
}
|
||||
ws.rooms[code] = room
|
||||
log.Printf("自动创建WebRTC房间: %s", code)
|
||||
log.Printf("尝试加入不存在的WebRTC房间: %s", code)
|
||||
return
|
||||
}
|
||||
|
||||
if client.Role == "sender" {
|
||||
@@ -164,15 +219,6 @@ func (ws *WebRTCService) addClientToRoom(code string, client *WebRTCClient) {
|
||||
}
|
||||
room.Sender.Connection.WriteJSON(peerJoinedMsg)
|
||||
}
|
||||
|
||||
// 如果接收方连接,且有保存的offer,立即发送给接收方
|
||||
if room.LastOffer != nil {
|
||||
log.Printf("向新连接的接收方发送保存的offer")
|
||||
err := client.Connection.WriteJSON(room.LastOffer)
|
||||
if err != nil {
|
||||
log.Printf("发送保存的offer失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,12 +256,6 @@ func (ws *WebRTCService) forwardMessage(roomCode string, fromClientID string, ms
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是offer消息,保存起来
|
||||
if msg.Type == "offer" {
|
||||
room.LastOffer = msg
|
||||
log.Printf("保存offer消息,等待接收方连接")
|
||||
}
|
||||
|
||||
var targetClient *WebRTCClient
|
||||
if room.Sender != nil && room.Sender.ID == fromClientID {
|
||||
// 消息来自sender,转发给receiver
|
||||
@@ -238,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()
|
||||
@@ -253,19 +324,39 @@ func (ws *WebRTCService) CreateRoom(code string) {
|
||||
}
|
||||
}
|
||||
|
||||
// CreateNewRoom 创建新房间并返回房间码
|
||||
// CreateNewRoom 创建新房间并返回房间码 - 确保不重复
|
||||
func (ws *WebRTCService) CreateNewRoom() string {
|
||||
code := ws.generatePickupCode()
|
||||
var code string
|
||||
|
||||
// 生成唯一房间码,确保不重复
|
||||
for {
|
||||
code = ws.generatePickupCode()
|
||||
ws.roomsMux.RLock()
|
||||
_, exists := ws.rooms[code]
|
||||
ws.roomsMux.RUnlock()
|
||||
|
||||
if !exists {
|
||||
break // 找到了不重复的代码
|
||||
}
|
||||
// 如果重复了,继续生成新的
|
||||
}
|
||||
|
||||
ws.CreateRoom(code)
|
||||
return code
|
||||
}
|
||||
|
||||
// generatePickupCode 生成6位取件码
|
||||
// generatePickupCode 生成6位取件码 - 统一规则:只使用大写字母和数字,排除0和O避免混淆
|
||||
func (ws *WebRTCService) generatePickupCode() string {
|
||||
// 只使用大写字母和数字,排除容易混淆的字符:数字0和字母O
|
||||
chars := "123456789ABCDEFGHIJKLMNPQRSTUVWXYZ"
|
||||
source := rand.NewSource(time.Now().UnixNano())
|
||||
rng := rand.New(source)
|
||||
code := rng.Intn(900000) + 100000
|
||||
return fmt.Sprintf("%d", code)
|
||||
|
||||
result := make([]byte, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
result[i] = chars[rng.Intn(len(chars))]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// cleanupExpiredRooms 定期清理过期房间
|
||||
@@ -331,6 +422,7 @@ func (ws *WebRTCService) notifyRoomDisconnection(roomCode string, disconnectedCl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WebRTCService) GetRoomStatus(code string) map[string]interface{} {
|
||||
ws.roomsMux.RLock()
|
||||
defer ws.roomsMux.RUnlock()
|
||||
@@ -344,11 +436,15 @@ func (ws *WebRTCService) GetRoomStatus(code string) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查房间是否已满(两个连接都已存在)
|
||||
isRoomFull := room.Sender != nil && room.Receiver != nil
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"exists": true,
|
||||
"sender_online": room.Sender != nil,
|
||||
"receiver_online": room.Receiver != nil,
|
||||
"is_room_full": isRoomFull,
|
||||
"created_at": room.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
3
internal/web/frontend/.gitkeep
Normal file
3
internal/web/frontend/.gitkeep
Normal file
@@ -0,0 +1,3 @@
|
||||
# Static Files Directory
|
||||
|
||||
This directory contains static files that will be embedded into the Go binary.
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user