WIP: unify live room api + android client updates
This commit is contained in:
parent
d6577ad99b
commit
214479e0ab
8
.idea/.gitignore
vendored
8
.idea/.gitignore
vendored
|
|
@ -1,8 +0,0 @@
|
|||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/zhibo.iml" filepath="$PROJECT_DIR$/.idea/zhibo.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
# Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
本设计文档描述了基于 SRS 的个人直播系统的技术架构和实现方案。系统采用前后端分离架构,使用 SRS 作为核心流媒体服务器处理视频流的接收和分发,Node.js 后端提供业务 API,React 前端提供用户界面。
|
||||
|
||||
### 技术栈选型
|
||||
|
||||
| 组件 | 技术选择 | 理由 |
|
||||
|------|----------|------|
|
||||
| 流媒体服务器 | SRS 5.0 | 高性能、低延迟、支持多协议、Docker 部署简单 |
|
||||
| 后端框架 | Node.js + Express | 轻量级、异步处理能力强、生态丰富 |
|
||||
| 前端框架 | React 18 | 组件化开发、生态成熟、性能优秀 |
|
||||
| 视频播放器 | flv.js + hls.js | flv.js 支持 HTTP-FLV 低延迟播放,hls.js 提供 HLS 兼容性 |
|
||||
| 容器化 | Docker + Docker Compose | 简化部署、环境一致性 |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 用户层 (Users) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ 主播 │ │ 观众 │ │
|
||||
│ │ (OBS/FFmpeg)│ │ (浏览器) │ │
|
||||
│ └──────┬──────┘ └────────┬────────┘ │
|
||||
│ │ RTMP 推流 │ HTTP 请求 │
|
||||
│ │ rtmp://host:1935/live/{streamKey} │ │
|
||||
└─────────┼─────────────────────────────────────────┼────────────┘
|
||||
│ │
|
||||
┌─────────┼─────────────────────────────────────────┼────────────┐
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ SRS Server │ │ React Frontend │ │
|
||||
│ │ (Docker) │ │ (Port 3000) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ RTMP: 1935 │◄───HTTP-FLV/HLS───│ flv.js/hls.js │ │
|
||||
│ │ HTTP: 8080 │ │ │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ │
|
||||
│ │ │ │
|
||||
│ │ HTTP Callback │ REST API │
|
||||
│ │ (on_publish/on_unpublish) │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Node.js API Server │ │
|
||||
│ │ (Port 3001) │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
|
||||
│ │ │ Room API │ │ Callback │ │ Room Store │ │ │
|
||||
│ │ │ Controller │ │ Handler │ │ (In-Memory) │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ 服务层 (Services) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 数据流说明
|
||||
|
||||
1. **推流流程**: 主播 → RTMP → SRS → HTTP Callback → API Server → 更新房间状态
|
||||
2. **观看流程**: 观众 → React App → 获取房间信息 → flv.js/hls.js → SRS HTTP-FLV/HLS
|
||||
3. **管理流程**: 用户 → React App → REST API → API Server → 房间 CRUD
|
||||
|
||||
## Components and Interfaces
|
||||
|
||||
### 1. SRS 流媒体服务器
|
||||
|
||||
**职责**: 接收 RTMP 推流,转换并分发 HTTP-FLV 和 HLS 流
|
||||
|
||||
**配置接口**:
|
||||
```conf
|
||||
# srs.conf
|
||||
listen 1935;
|
||||
max_connections 1000;
|
||||
daemon off;
|
||||
srs_log_tank console;
|
||||
|
||||
http_server {
|
||||
enabled on;
|
||||
listen 8080;
|
||||
dir ./objs/nginx/html;
|
||||
}
|
||||
|
||||
vhost __defaultVhost__ {
|
||||
hls {
|
||||
enabled on;
|
||||
hls_fragment 2;
|
||||
hls_window 10;
|
||||
hls_path ./objs/nginx/html;
|
||||
hls_m3u8_file [app]/[stream].m3u8;
|
||||
hls_ts_file [app]/[stream]-[seq].ts;
|
||||
}
|
||||
|
||||
http_remux {
|
||||
enabled on;
|
||||
mount [vhost]/[app]/[stream].flv;
|
||||
}
|
||||
|
||||
http_hooks {
|
||||
enabled on;
|
||||
on_publish http://api-server:3001/api/srs/on_publish;
|
||||
on_unpublish http://api-server:3001/api/srs/on_unpublish;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. API Server (Node.js + Express)
|
||||
|
||||
**职责**: 提供业务 API,处理 SRS 回调,管理房间数据
|
||||
|
||||
#### 2.1 Room API Controller
|
||||
|
||||
```typescript
|
||||
// 接口定义
|
||||
interface RoomController {
|
||||
// 获取所有房间
|
||||
GET /api/rooms -> Room[]
|
||||
|
||||
// 创建房间
|
||||
POST /api/rooms { title: string, streamerName: string } -> Room
|
||||
|
||||
// 获取单个房间
|
||||
GET /api/rooms/:id -> Room
|
||||
|
||||
// 删除房间
|
||||
DELETE /api/rooms/:id -> { success: boolean }
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 SRS Callback Handler
|
||||
|
||||
```typescript
|
||||
// SRS 回调接口
|
||||
interface SRSCallbackHandler {
|
||||
// 推流开始回调
|
||||
POST /api/srs/on_publish {
|
||||
action: 'on_publish',
|
||||
app: string, // 应用名,如 'live'
|
||||
stream: string // 流名称,即 streamKey
|
||||
} -> { code: 0 }
|
||||
|
||||
// 推流结束回调
|
||||
POST /api/srs/on_unpublish {
|
||||
action: 'on_unpublish',
|
||||
app: string,
|
||||
stream: string
|
||||
} -> { code: 0 }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. React Frontend
|
||||
|
||||
**职责**: 提供用户界面,包括房间列表、直播播放、房间创建
|
||||
|
||||
#### 3.1 组件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── RoomList.jsx # 房间列表组件
|
||||
│ ├── RoomCard.jsx # 房间卡片组件
|
||||
│ ├── LivePlayer.jsx # 直播播放器组件
|
||||
│ ├── CreateRoom.jsx # 创建房间弹窗
|
||||
│ └── StreamInfo.jsx # 推流信息展示
|
||||
├── pages/
|
||||
│ ├── HomePage.jsx # 首页(房间列表)
|
||||
│ └── RoomPage.jsx # 房间详情页
|
||||
├── services/
|
||||
│ └── api.js # API 调用封装
|
||||
└── App.jsx # 应用入口
|
||||
```
|
||||
|
||||
#### 3.2 LivePlayer 组件接口
|
||||
|
||||
```typescript
|
||||
interface LivePlayerProps {
|
||||
room: Room;
|
||||
preferFlv?: boolean; // 优先使用 FLV(低延迟)
|
||||
}
|
||||
|
||||
// 播放器内部逻辑
|
||||
// 1. 检测浏览器是否支持 flv.js (需要 MSE 支持)
|
||||
// 2. 支持则使用 HTTP-FLV,否则降级到 HLS
|
||||
// 3. 监听播放错误,自动重连
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### Room 数据模型
|
||||
|
||||
```typescript
|
||||
interface Room {
|
||||
id: string; // 房间唯一标识 (UUID)
|
||||
title: string; // 房间标题
|
||||
streamerName: string; // 主播名称
|
||||
streamKey: string; // 推流密钥 (与 id 相同)
|
||||
isLive: boolean; // 是否正在直播
|
||||
viewerCount: number; // 观看人数
|
||||
createdAt: string; // 创建时间 (ISO 8601)
|
||||
startedAt?: string; // 开播时间 (ISO 8601)
|
||||
}
|
||||
```
|
||||
|
||||
### API 响应格式
|
||||
|
||||
```typescript
|
||||
// 成功响应
|
||||
interface SuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 错误响应
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
// SRS 回调响应
|
||||
interface SRSCallbackResponse {
|
||||
code: number; // 0 表示成功
|
||||
}
|
||||
```
|
||||
|
||||
### 流地址格式
|
||||
|
||||
```typescript
|
||||
interface StreamUrls {
|
||||
rtmp: string; // rtmp://host:1935/live/{streamKey}
|
||||
flv: string; // http://host:8080/live/{streamKey}.flv
|
||||
hls: string; // http://host:8080/live/{streamKey}.m3u8
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Correctness Properties
|
||||
|
||||
*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
||||
|
||||
### Property 1: Room Creation Round-Trip Consistency
|
||||
|
||||
*For any* valid room creation request with non-empty title and streamer name, creating the room and then retrieving it by ID SHALL return a room with matching title, streamer name, a unique stream key, and a valid creation timestamp.
|
||||
|
||||
**Validates: Requirements 1.1, 1.4**
|
||||
|
||||
### Property 2: New Rooms Start Offline
|
||||
|
||||
*For any* newly created room, the initial `isLive` status SHALL be `false`.
|
||||
|
||||
**Validates: Requirements 1.2**
|
||||
|
||||
### Property 3: Empty Title Rejection
|
||||
|
||||
*For any* room creation request where the title is empty or consists only of whitespace characters, the system SHALL reject the request and return a validation error without creating a room.
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 4: Publish Callback Status Transition
|
||||
|
||||
*For any* room that exists in the system, when a publish callback is received with that room's stream key, the room's `isLive` status SHALL transition to `true`. When an unpublish callback is received, the status SHALL transition to `false`.
|
||||
|
||||
**Validates: Requirements 2.3, 2.5**
|
||||
|
||||
### Property 5: Stream URLs Contain Required Formats
|
||||
|
||||
*For any* room returned by the API, the stream URLs SHALL include both HTTP-FLV (`.flv`) and HLS (`.m3u8`) format URLs containing the room's stream key.
|
||||
|
||||
**Validates: Requirements 3.1**
|
||||
|
||||
### Property 6: Room List Completeness
|
||||
|
||||
*For any* set of created rooms, requesting the room list SHALL return all rooms with their current status, title, and streamer name intact.
|
||||
|
||||
**Validates: Requirements 4.1, 4.2**
|
||||
|
||||
## Error Handling
|
||||
|
||||
### API Error Codes
|
||||
|
||||
| 错误码 | 描述 | HTTP 状态码 |
|
||||
|--------|------|-------------|
|
||||
| `VALIDATION_ERROR` | 请求参数验证失败 | 400 |
|
||||
| `ROOM_NOT_FOUND` | 房间不存在 | 404 |
|
||||
| `STREAM_KEY_INVALID` | 推流密钥无效 | 401 |
|
||||
| `INTERNAL_ERROR` | 服务器内部错误 | 500 |
|
||||
|
||||
### 错误处理策略
|
||||
|
||||
1. **API 层**: 统一错误响应格式,包含错误码和消息
|
||||
2. **SRS 回调**: 返回 `{ code: 0 }` 表示成功,非零表示失败
|
||||
3. **前端**: 显示友好的错误提示,支持重试操作
|
||||
4. **播放器**: 自动重连机制,最多重试 3 次
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### 单元测试
|
||||
|
||||
使用 Jest 进行单元测试,覆盖以下模块:
|
||||
|
||||
1. **Room Service**: 房间 CRUD 操作
|
||||
2. **Validation**: 输入验证逻辑
|
||||
3. **URL Generator**: 流地址生成
|
||||
|
||||
### 属性测试 (Property-Based Testing)
|
||||
|
||||
使用 fast-check 库进行属性测试:
|
||||
|
||||
1. **Property 1**: 房间创建往返一致性
|
||||
2. **Property 2**: 新房间初始状态
|
||||
3. **Property 3**: 空标题拒绝
|
||||
4. **Property 4**: 回调状态转换
|
||||
5. **Property 5**: 流地址格式
|
||||
6. **Property 6**: 房间列表完整性
|
||||
|
||||
### 测试配置
|
||||
|
||||
```javascript
|
||||
// jest.config.js
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/*.test.js', '**/*.property.test.js'],
|
||||
collectCoverageFrom: ['server/**/*.js'],
|
||||
coverageThreshold: {
|
||||
global: { branches: 80, functions: 80, lines: 80 }
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 属性测试示例
|
||||
|
||||
```javascript
|
||||
// 每个属性测试运行 100 次迭代
|
||||
// 标注格式: **Feature: live-streaming-system, Property {number}: {property_text}**
|
||||
|
||||
import fc from 'fast-check';
|
||||
|
||||
// **Feature: live-streaming-system, Property 2: New Rooms Start Offline**
|
||||
test('newly created rooms should have isLive = false', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1 }), // title
|
||||
fc.string({ minLength: 1 }), // streamerName
|
||||
(title, streamerName) => {
|
||||
const room = createRoom({ title, streamerName });
|
||||
return room.isLive === false;
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
```
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
本文档定义了一个基于 SRS(Simple Realtime Server)的个人直播系统的功能需求。该系统允许用户创建直播间、进行实时视频推流、观看直播内容,并提供基本的直播间管理功能。系统采用 SRS 作为核心流媒体服务器,支持 RTMP 推流和 HTTP-FLV/HLS 播放,具有高性能、低延迟的特点。
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Live Streaming System(直播系统)**: 提供视频直播功能的完整系统,包含前端播放器、后端 API 服务和 SRS 流媒体服务
|
||||
- **SRS(Simple Realtime Server)**: 开源的高性能流媒体服务器,支持 RTMP、HLS、HTTP-FLV、WebRTC 等协议
|
||||
- **Streamer(主播)**: 创建直播间并推送视频流的用户
|
||||
- **Viewer(观众)**: 观看直播内容的用户
|
||||
- **Live Room(直播间)**: 主播进行直播的虚拟空间,包含标题、主播信息和直播状态
|
||||
- **Stream Key(推流密钥)**: 用于验证主播身份的唯一标识符
|
||||
- **RTMP(Real-Time Messaging Protocol)**: 用于主播推流的实时消息传输协议
|
||||
- **HTTP-FLV**: 基于 HTTP 的 FLV 流协议,延迟低于 HLS,适合实时直播
|
||||
- **HLS(HTTP Live Streaming)**: 基于 HTTP 的直播流协议,兼容性好但延迟较高
|
||||
- **SRS Callback**: SRS 的 HTTP 回调机制,用于通知业务服务器推流状态变化
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1
|
||||
|
||||
**User Story:** As a streamer, I want to create a live room, so that I can start broadcasting my content to viewers.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a streamer submits a room creation request with title and streamer name, THE Live Streaming System SHALL create a new live room and return a unique stream key
|
||||
2. WHEN a live room is created, THE Live Streaming System SHALL initialize the room status as "offline"
|
||||
3. WHEN a streamer provides an empty title, THE Live Streaming System SHALL reject the creation request and return a validation error
|
||||
4. WHEN a live room is created, THE Live Streaming System SHALL store the room information including title, streamer name, stream key, and creation timestamp
|
||||
|
||||
### Requirement 2
|
||||
|
||||
**User Story:** As a streamer, I want to push my video stream to the server, so that viewers can watch my live content.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a streamer connects to the SRS RTMP endpoint with a valid stream key, THE SRS Server SHALL accept the connection and begin receiving the video stream
|
||||
2. WHEN SRS receives a publish event, THE SRS Server SHALL trigger an HTTP callback to notify the API service of the stream start
|
||||
3. WHEN the API service receives a publish callback, THE Live Streaming System SHALL update the corresponding room status to "live"
|
||||
4. WHEN SRS receives an unpublish event, THE SRS Server SHALL trigger an HTTP callback to notify the API service of the stream end
|
||||
5. WHEN the API service receives an unpublish callback, THE Live Streaming System SHALL update the room status to "offline"
|
||||
|
||||
### Requirement 3
|
||||
|
||||
**User Story:** As a viewer, I want to watch live streams, so that I can enjoy the content broadcasted by streamers.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a viewer requests to watch a live room, THE Live Streaming System SHALL provide both HTTP-FLV and HLS stream URLs for playback
|
||||
2. WHEN a viewer accesses an HTTP-FLV stream URL, THE Live Streaming System SHALL deliver the video with latency under 3 seconds
|
||||
3. WHEN a viewer accesses an HLS stream URL, THE Live Streaming System SHALL deliver the video with latency under 10 seconds
|
||||
4. WHEN a viewer attempts to watch an offline room, THE Live Streaming System SHALL display an appropriate offline message
|
||||
5. WHEN the stream quality changes, THE Live Streaming System SHALL continue playback without interruption
|
||||
|
||||
### Requirement 4
|
||||
|
||||
**User Story:** As a viewer, I want to browse available live rooms, so that I can discover content to watch.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a viewer requests the room list, THE Live Streaming System SHALL return all available rooms with their current status
|
||||
2. WHEN displaying the room list, THE Live Streaming System SHALL show room title, streamer name, and live status for each room
|
||||
3. WHEN a room's live status changes, THE Live Streaming System SHALL reflect the updated status within 5 seconds on subsequent requests
|
||||
4. WHEN no rooms exist, THE Live Streaming System SHALL return an empty list with appropriate messaging
|
||||
|
||||
### Requirement 5
|
||||
|
||||
**User Story:** As a system administrator, I want to deploy and configure SRS, so that the streaming service runs reliably.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN deploying SRS, THE Live Streaming System SHALL use Docker for containerized deployment
|
||||
2. WHEN configuring SRS, THE Live Streaming System SHALL enable RTMP input on port 1935
|
||||
3. WHEN configuring SRS, THE Live Streaming System SHALL enable HTTP-FLV output on port 8080
|
||||
4. WHEN configuring SRS, THE Live Streaming System SHALL enable HLS output with 2-second segments
|
||||
5. WHEN configuring SRS, THE Live Streaming System SHALL set up HTTP callbacks for publish and unpublish events
|
||||
|
||||
### Requirement 6
|
||||
|
||||
**User Story:** As a user, I want a responsive web interface, so that I can access the live streaming platform from any device.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN a user accesses the platform, THE Live Streaming System SHALL display a responsive interface that adapts to screen sizes from 320px to 1920px width
|
||||
2. WHEN a user interacts with the video player, THE Live Streaming System SHALL provide play, pause, and volume controls
|
||||
3. WHEN a user navigates between pages, THE Live Streaming System SHALL maintain smooth transitions without full page reloads
|
||||
4. WHEN the interface loads, THE Live Streaming System SHALL display the main content within 3 seconds on a standard broadband connection
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
# Implementation Tasks
|
||||
|
||||
## Task 1: 项目初始化和 Docker 配置
|
||||
|
||||
- [ ] 1.1 创建项目根目录结构 (server/, client/, docker/)
|
||||
- [ ] 1.2 创建 package.json 配置后端依赖
|
||||
- [ ] 1.3 创建 SRS 配置文件 (docker/srs/srs.conf)
|
||||
- [ ] 1.4 创建 docker-compose.yml 配置 SRS 和 API 服务
|
||||
- [ ] 1.5 创建 .env.example 环境变量模板
|
||||
|
||||
## Task 2: 后端 API 服务 - 房间管理
|
||||
|
||||
- [ ] 2.1 创建 Express 服务入口 (server/index.js)
|
||||
- [ ] 2.2 实现房间数据存储模块 (server/store/roomStore.js)
|
||||
- [ ] 2.3 实现房间 API 路由 (server/routes/rooms.js)
|
||||
- GET /api/rooms - 获取所有房间
|
||||
- POST /api/rooms - 创建房间
|
||||
- GET /api/rooms/:id - 获取单个房间
|
||||
- DELETE /api/rooms/:id - 删除房间
|
||||
- [ ] 2.4 实现输入验证中间件 (server/middleware/validate.js)
|
||||
- [ ] 2.5 实现统一错误处理 (server/middleware/errorHandler.js)
|
||||
|
||||
## Task 3: 后端 API 服务 - SRS 回调处理
|
||||
|
||||
- [ ] 3.1 实现 SRS 回调路由 (server/routes/srs.js)
|
||||
- POST /api/srs/on_publish - 推流开始回调
|
||||
- POST /api/srs/on_unpublish - 推流结束回调
|
||||
- [ ] 3.2 实现流地址生成工具 (server/utils/streamUrl.js)
|
||||
- [ ] 3.3 添加回调日志记录
|
||||
|
||||
## Task 4: 前端 - 项目搭建和基础组件
|
||||
|
||||
- [ ] 4.1 初始化 React 项目 (client/)
|
||||
- [ ] 4.2 安装依赖 (flv.js, hls.js, axios)
|
||||
- [ ] 4.3 创建 API 服务封装 (client/src/services/api.js)
|
||||
- [ ] 4.4 创建全局样式 (client/src/index.css)
|
||||
- [ ] 4.5 创建 App 入口和路由 (client/src/App.jsx)
|
||||
|
||||
## Task 5: 前端 - 房间列表页面
|
||||
|
||||
- [ ] 5.1 创建 RoomCard 组件 (client/src/components/RoomCard.jsx)
|
||||
- [ ] 5.2 创建 RoomList 组件 (client/src/components/RoomList.jsx)
|
||||
- [ ] 5.3 创建 HomePage 页面 (client/src/pages/HomePage.jsx)
|
||||
- [ ] 5.4 实现房间列表自动刷新
|
||||
|
||||
## Task 6: 前端 - 创建直播间功能
|
||||
|
||||
- [ ] 6.1 创建 CreateRoom 弹窗组件 (client/src/components/CreateRoom.jsx)
|
||||
- [ ] 6.2 创建 StreamInfo 推流信息组件 (client/src/components/StreamInfo.jsx)
|
||||
- [ ] 6.3 实现复制推流地址功能
|
||||
|
||||
## Task 7: 前端 - 直播播放器
|
||||
|
||||
- [ ] 7.1 创建 LivePlayer 组件 (client/src/components/LivePlayer.jsx)
|
||||
- [ ] 7.2 实现 flv.js 播放器集成 (HTTP-FLV 低延迟)
|
||||
- [ ] 7.3 实现 hls.js 降级播放 (HLS 兼容性)
|
||||
- [ ] 7.4 实现播放器自动重连机制
|
||||
- [ ] 7.5 创建 RoomPage 直播间页面 (client/src/pages/RoomPage.jsx)
|
||||
|
||||
## Task 8: 集成测试和部署
|
||||
|
||||
- [ ] 8.1 编写属性测试 (server/tests/room.property.test.js)
|
||||
- [ ] 8.2 编写 API 集成测试 (server/tests/api.test.js)
|
||||
- [ ] 8.3 创建启动脚本 (scripts/start.sh, scripts/start.bat)
|
||||
- [ ] 8.4 编写 README.md 使用文档
|
||||
- [ ] 8.5 测试完整推流和观看流程
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -1,2 +0,0 @@
|
|||
{
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import com.zbkj.front.response.live.StreamUrlsResponse;
|
|||
import com.zbkj.service.service.LiveRoomService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
|
@ -79,7 +78,10 @@ public class LiveRoomController {
|
|||
|
||||
private LiveRoomResponse toResponse(LiveRoom room, String requestHost) {
|
||||
LiveRoomResponse resp = new LiveRoomResponse();
|
||||
BeanUtils.copyProperties(room, resp);
|
||||
resp.setId(room.getId() == null ? null : String.valueOf(room.getId()));
|
||||
resp.setTitle(room.getTitle());
|
||||
resp.setStreamerName(room.getStreamerName());
|
||||
resp.setStreamKey(room.getStreamKey());
|
||||
resp.setIsLive(room.getIsLive() != null && room.getIsLive() == 1);
|
||||
resp.setViewerCount(0);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import lombok.Data;
|
|||
public class LiveRoomResponse {
|
||||
|
||||
@ApiModelProperty(value = "房间ID")
|
||||
private Integer id;
|
||||
private String id;
|
||||
|
||||
@ApiModelProperty(value = "标题")
|
||||
private String title;
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@ android {
|
|||
}
|
||||
|
||||
val apiBaseUrlEmulator = (localProps.getProperty("api.base_url_emulator")
|
||||
?: "http://10.0.2.2:25001/api/").trim()
|
||||
?: "http://10.0.2.2:8081/").trim()
|
||||
|
||||
val apiBaseUrlDevice = (localProps.getProperty("api.base_url_device")
|
||||
?: "http://192.168.1.100:25001/api/").trim()
|
||||
?: "http://192.168.1.100:8081/").trim()
|
||||
|
||||
// 模拟器使用服务器地址
|
||||
buildConfigField("String", "API_BASE_URL_EMULATOR", "\"$apiBaseUrlEmulator\"")
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ public class MainActivity extends AppCompatActivity {
|
|||
binding = ActivityMainBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
ApiClient.getService(getApplicationContext());
|
||||
|
||||
// 立即显示缓存数据,提升启动速度
|
||||
setupRecyclerView();
|
||||
setupUI();
|
||||
|
|
@ -417,7 +419,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
|
||||
|
||||
ApiClient.getService().createRoom(new CreateRoomRequest(title, streamer))
|
||||
ApiClient.getService(getApplicationContext()).createRoom(new CreateRoomRequest(title, streamer))
|
||||
.enqueue(new Callback<ApiResponse<Room>>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) {
|
||||
|
|
@ -425,8 +427,9 @@ public class MainActivity extends AppCompatActivity {
|
|||
|
||||
ApiResponse<Room> body = response.body();
|
||||
Room room = body != null ? body.getData() : null;
|
||||
if (!response.isSuccessful() || room == null) {
|
||||
Toast.makeText(MainActivity.this, "创建失败", Toast.LENGTH_SHORT).show();
|
||||
if (!response.isSuccessful() || body == null || !body.isOk() || room == null) {
|
||||
String msg = body != null && !TextUtils.isEmpty(body.getMessage()) ? body.getMessage() : "创建失败";
|
||||
Toast.makeText(MainActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -535,14 +538,16 @@ public class MainActivity extends AppCompatActivity {
|
|||
binding.loading.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
ApiClient.getService().getRooms().enqueue(new Callback<ApiResponse<List<Room>>>() {
|
||||
ApiClient.getService(getApplicationContext()).getRooms().enqueue(new Callback<ApiResponse<List<Room>>>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiResponse<List<Room>>> call, Response<ApiResponse<List<Room>>> response) {
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
binding.swipeRefresh.setRefreshing(false);
|
||||
isFetching = false;
|
||||
ApiResponse<List<Room>> body = response.body();
|
||||
List<Room> rooms = body != null && body.getData() != null ? body.getData() : Collections.emptyList();
|
||||
List<Room> rooms = response.isSuccessful() && body != null && body.isOk() && body.getData() != null
|
||||
? body.getData()
|
||||
: Collections.emptyList();
|
||||
if (rooms == null || rooms.isEmpty()) {
|
||||
rooms = buildDemoRooms(12);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
ApiClient.getService(getApplicationContext());
|
||||
|
||||
roomId = getIntent().getStringExtra(EXTRA_ROOM_ID);
|
||||
triedAltUrl = false;
|
||||
|
||||
|
|
@ -310,19 +312,21 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
binding.loading.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
ApiClient.getService().getRoom(roomId).enqueue(new Callback<ApiResponse<Room>>() {
|
||||
ApiClient.getService(getApplicationContext()).getRoom(roomId).enqueue(new Callback<ApiResponse<Room>>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) {
|
||||
if (isFinishing() || isDestroyed()) return;
|
||||
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
boolean firstLoad = isFirstLoad;
|
||||
isFirstLoad = false;
|
||||
|
||||
ApiResponse<Room> body = response.body();
|
||||
Room data = body != null ? body.getData() : null;
|
||||
if (!response.isSuccessful() || data == null) {
|
||||
if (isFirstLoad) {
|
||||
Toast.makeText(RoomDetailActivity.this, "房间不存在", Toast.LENGTH_SHORT).show();
|
||||
if (!response.isSuccessful() || body == null || !body.isOk() || data == null) {
|
||||
if (firstLoad) {
|
||||
String msg = body != null && !TextUtils.isEmpty(body.getMessage()) ? body.getMessage() : "房间不存在";
|
||||
Toast.makeText(RoomDetailActivity.this, msg, Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ package com.example.livestreaming.net;
|
|||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.example.livestreaming.BuildConfig;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.logging.HttpLoggingInterceptor;
|
||||
import retrofit2.Retrofit;
|
||||
import retrofit2.converter.gson.GsonConverterFactory;
|
||||
|
|
@ -15,6 +20,7 @@ public final class ApiClient {
|
|||
private static final String TAG = "ApiClient";
|
||||
private static volatile Retrofit retrofit;
|
||||
private static volatile ApiService service;
|
||||
private static volatile Context appContext;
|
||||
|
||||
private ApiClient() {
|
||||
}
|
||||
|
|
@ -47,6 +53,13 @@ public final class ApiClient {
|
|||
}
|
||||
|
||||
public static ApiService getService() {
|
||||
return getService(null);
|
||||
}
|
||||
|
||||
public static ApiService getService(@Nullable Context context) {
|
||||
if (context != null) {
|
||||
appContext = context.getApplicationContext();
|
||||
}
|
||||
if (service != null) return service;
|
||||
synchronized (ApiClient.class) {
|
||||
if (service != null) return service;
|
||||
|
|
@ -54,8 +67,19 @@ public final class ApiClient {
|
|||
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
|
||||
logging.setLevel(HttpLoggingInterceptor.Level.BASIC);
|
||||
|
||||
Interceptor auth = chain -> {
|
||||
Context ctx = ApiClient.appContext;
|
||||
String token = ctx != null ? AuthStore.getToken(ctx) : null;
|
||||
okhttp3.Request.Builder req = chain.request().newBuilder();
|
||||
if (token != null && !token.trim().isEmpty()) {
|
||||
req.header("Authori-zation", token.trim());
|
||||
}
|
||||
return chain.proceed(req.build());
|
||||
};
|
||||
|
||||
OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder()
|
||||
.addInterceptor(logging)
|
||||
.addInterceptor(auth)
|
||||
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
|
|
|
|||
|
|
@ -4,17 +4,28 @@ import com.google.gson.annotations.SerializedName;
|
|||
|
||||
public class ApiResponse<T> {
|
||||
|
||||
@SerializedName("success")
|
||||
private Boolean success;
|
||||
@SerializedName("code")
|
||||
private Long code;
|
||||
|
||||
@SerializedName("message")
|
||||
private String message;
|
||||
|
||||
@SerializedName("data")
|
||||
private T data;
|
||||
|
||||
public Boolean getSuccess() {
|
||||
return success;
|
||||
public Long getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public boolean isOk() {
|
||||
return code != null && code == 200L;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,18 @@ import retrofit2.http.Path;
|
|||
import retrofit2.http.POST;
|
||||
|
||||
public interface ApiService {
|
||||
@GET("rooms")
|
||||
@POST("api/front/login")
|
||||
Call<ApiResponse<LoginResponse>> login(@Body LoginRequest body);
|
||||
|
||||
@GET("api/front/live/public/rooms")
|
||||
Call<ApiResponse<List<Room>>> getRooms();
|
||||
|
||||
@POST("rooms")
|
||||
@POST("api/front/live/rooms")
|
||||
Call<ApiResponse<Room>> createRoom(@Body CreateRoomRequest body);
|
||||
|
||||
@GET("rooms/{id}")
|
||||
@GET("api/front/live/public/rooms/{id}")
|
||||
Call<ApiResponse<Room>> getRoom(@Path("id") String id);
|
||||
|
||||
@DELETE("rooms/{id}")
|
||||
@DELETE("api/front/live/rooms/{id}")
|
||||
Call<ApiResponse<Object>> deleteRoom(@Path("id") String id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
package com.example.livestreaming.net;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class AuthStore {
|
||||
|
||||
private static final String PREFS = "auth_prefs";
|
||||
private static final String KEY_TOKEN = "token";
|
||||
|
||||
private AuthStore() {
|
||||
}
|
||||
|
||||
public static void setToken(Context context, @Nullable String token) {
|
||||
if (context == null) return;
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(KEY_TOKEN)
|
||||
.apply();
|
||||
return;
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_TOKEN, token.trim())
|
||||
.apply();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String getToken(Context context) {
|
||||
if (context == null) return null;
|
||||
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_TOKEN, null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.example.livestreaming.net;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
public class LoginRequest {
|
||||
|
||||
@SerializedName("account")
|
||||
private final String account;
|
||||
|
||||
@SerializedName("password")
|
||||
private final String password;
|
||||
|
||||
public LoginRequest(String account, String password) {
|
||||
this.account = account;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getAccount() {
|
||||
return account;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.example.livestreaming.net;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
public class LoginResponse {
|
||||
|
||||
@SerializedName("token")
|
||||
private String token;
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user