WIP: unify live room api + android client updates

This commit is contained in:
xiao12feng8 2025-12-21 18:55:17 +08:00
parent d6577ad99b
commit 214479e0ab
19 changed files with 145 additions and 563 deletions

8
.idea/.gitignore vendored
View File

@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -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>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -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>

View File

@ -1,353 +0,0 @@
# Design Document
## Overview
本设计文档描述了基于 SRS 的个人直播系统的技术架构和实现方案。系统采用前后端分离架构,使用 SRS 作为核心流媒体服务器处理视频流的接收和分发Node.js 后端提供业务 APIReact 前端提供用户界面。
### 技术栈选型
| 组件 | 技术选择 | 理由 |
|------|----------|------|
| 流媒体服务器 | 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 }
);
});
```

View File

@ -1,89 +0,0 @@
# Requirements Document
## Introduction
本文档定义了一个基于 SRSSimple Realtime Server的个人直播系统的功能需求。该系统允许用户创建直播间、进行实时视频推流、观看直播内容并提供基本的直播间管理功能。系统采用 SRS 作为核心流媒体服务器,支持 RTMP 推流和 HTTP-FLV/HLS 播放,具有高性能、低延迟的特点。
## Glossary
- **Live Streaming System直播系统**: 提供视频直播功能的完整系统,包含前端播放器、后端 API 服务和 SRS 流媒体服务
- **SRSSimple Realtime Server**: 开源的高性能流媒体服务器,支持 RTMP、HLS、HTTP-FLV、WebRTC 等协议
- **Streamer主播**: 创建直播间并推送视频流的用户
- **Viewer观众**: 观看直播内容的用户
- **Live Room直播间**: 主播进行直播的虚拟空间,包含标题、主播信息和直播状态
- **Stream Key推流密钥**: 用于验证主播身份的唯一标识符
- **RTMPReal-Time Messaging Protocol**: 用于主播推流的实时消息传输协议
- **HTTP-FLV**: 基于 HTTP 的 FLV 流协议,延迟低于 HLS适合实时直播
- **HLSHTTP 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

View File

@ -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 测试完整推流和观看流程

View File

@ -1,2 +0,0 @@
{
}

View File

@ -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);

View File

@ -9,7 +9,7 @@ import lombok.Data;
public class LiveRoomResponse {
@ApiModelProperty(value = "房间ID")
private Integer id;
private String id;
@ApiModelProperty(value = "标题")
private String title;

View File

@ -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\"")

View File

@ -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);
}

View File

@ -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;

View File

@ -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)

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}