Compare commits

..

No commits in common. "e7649df1af5c7238b033a69dad5db97cc8ea8aaf" and "2d88a5534813b8295c826f020cd0505d90999add" have entirely different histories.

51 changed files with 597 additions and 6064 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,340 +0,0 @@
# Android应用接口真实调用情况分析报告
## 📊 分析概述
本报告详细分析了Android应用中对后端接口的**真实调用情况**,区分了"已定义但未使用"和"已实际调用"的接口。
**分析时间**: 2024年12月30日
**分析范围**: android-app 和 java-backend 全部代码
**分析方法**: 代码静态分析 + 接口定义对比
---
## ✅ 已真实调用的接口(共 45+ 个)
### 1. 用户认证模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `POST /api/front/login` | LoginActivity.java | ✅ 真实调用 |
| `POST /api/front/register` | LoginActivity.java | ✅ 真实调用 |
| `POST /api/front/sendCode` | LoginActivity.java | ✅ 真实调用 |
| `GET /api/front/logout` | SettingsPageActivity.java | ✅ 真实调用 |
| `GET /api/front/user` | ProfileActivity.java | ✅ 真实调用 |
### 2. 直播间模块 (85% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/live/public/rooms` | MainActivity.java | ✅ 真实调用 |
| `POST /api/front/live/rooms` | MainActivity.java | ✅ 真实调用 |
| `GET /api/front/live/public/rooms/{id}` | RoomDetailActivity.java | ✅ 真实调用 |
| `DELETE /api/front/live/rooms/{id}` | ProfileActivity.java | ✅ 真实调用 |
| `POST /api/front/live/follow` | RoomDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/live/room/{id}/start` | RoomDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/live/room/{id}/stop` | RoomDetailActivity.java | ✅ 真实调用 |
| `POST /api/live/online/broadcast/{roomId}` | RoomDetailActivity.java | ✅ 真实调用 |
| `GET /api/front/live/public/rooms/{roomId}/messages` | RoomDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/live/rooms/{roomId}/gift` | RoomDetailActivity.java | ✅ 真实调用 |
**未调用接口**:
- ❌ `GET /api/front/live/public/rooms/{roomId}/viewers/count` - 已定义但未使用
- ❌ `GET /api/front/live/rooms/{roomId}/viewers` - 已定义但未使用
### 3. 礼物打赏模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/gift/list` | RoomDetailActivity.java:1190 | ✅ 真实调用 |
| `GET /api/front/gift/balance` | RoomDetailActivity.java:1588 | ✅ 真实调用 |
| `POST /api/front/gift/send` | RoomDetailActivity.java:1636 | ✅ 真实调用 |
| `GET /api/front/gift/recharge/options` | RoomDetailActivity.java:1387 | ✅ 真实调用 |
| `POST /api/front/gift/recharge/create` | RoomDetailActivity.java:1460 | ✅ 真实调用 |
| `POST /api/front/pay/payment` | RoomDetailActivity.java:1537 | ✅ 真实调用 |
### 4. 私聊会话模块 (90% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/conversations` | MessagesActivity.java | ✅ 真实调用 |
| `GET /api/front/conversations/search` | MessagesActivity.java | ✅ 真实调用 |
| `POST /api/front/conversations/with/{otherUserId}` | ChatActivity.java | ✅ 真实调用 |
| `POST /api/front/conversations/{id}/read` | ChatActivity.java | ✅ 真实调用 |
| `DELETE /api/front/conversations/{id}` | MessagesActivity.java | ✅ 真实调用 |
| `GET /api/front/conversations/{id}/messages` | ChatActivity.java | ✅ 真实调用 |
| `POST /api/front/conversations/{id}/messages` | ChatActivity.java | ✅ 真实调用 |
**未调用接口**:
- ❌ `DELETE /api/front/conversations/messages/{id}` - 已定义但未使用(删除单条消息功能未实现)
### 5. 好友管理模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/friends` | MyFriendsActivity.java | ✅ 真实调用 |
| `DELETE /api/front/friends/{friendId}` | MyFriendsActivity.java | ✅ 真实调用 |
| `POST /api/front/friends/block/{friendId}` | MyFriendsActivity.java | ✅ 真实调用 |
| `POST /api/front/friends/unblock/{friendId}` | BlockedListActivity.java | ✅ 真实调用 |
| `GET /api/front/friends/blocked` | BlockedListActivity.java | ✅ 真实调用 |
| `GET /api/front/users/search` | AddFriendActivity.java:62 | ✅ 真实调用 |
| `POST /api/front/friends/request` | AddFriendActivity.java:142 | ✅ 真实调用 |
| `GET /api/front/friends/requests` | FriendRequestsActivity.java | ✅ 真实调用 |
| `POST /api/front/friends/requests/{requestId}/handle` | FriendRequestsActivity.java | ✅ 真实调用 |
### 6. 关注功能模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `POST /api/front/follow/follow` | UserProfileReadOnlyActivity.java:141 | ✅ 真实调用 |
| `POST /api/front/follow/unfollow` | UserProfileReadOnlyActivity.java | ✅ 真实调用 |
| `GET /api/front/follow/status/{userId}` | UserProfileReadOnlyActivity.java:105 | ✅ 真实调用 |
| `POST /api/front/follow/status/batch` | FishPondActivity.java | ✅ 真实调用 |
| `GET /api/front/follow/following` | FollowingListActivity.java:62 | ✅ 真实调用 |
| `GET /api/front/follow/followers` | FansListActivity.java:62 | ✅ 真实调用 |
| `GET /api/front/follow/stats` | ProfileActivity.java:684 | ✅ 真实调用 |
### 7. 作品管理模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `POST /api/front/works/publish` | PublishWorkActivity.java:766 | ✅ 真实调用 |
| `POST /api/front/works/update` | EditWorkActivity.java | ✅ 真实调用 |
| `POST /api/front/works/delete/{worksId}` | ProfileActivity.java | ✅ 真实调用 |
| `GET /api/front/works/detail/{worksId}` | WorkDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/works/search` | SearchActivity.java | ✅ 真实调用 |
| `GET /api/front/works/user/{userId}` | ProfileActivity.java | ✅ 真实调用 |
| `POST /api/front/works/like/{worksId}` | WorkDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/works/unlike/{worksId}` | WorkDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/works/collect/{worksId}` | WorkDetailActivity.java | ✅ 真实调用 |
| `POST /api/front/works/uncollect/{worksId}` | WorkDetailActivity.java | ✅ 真实调用 |
| `GET /api/front/works/my/liked` | ProfileActivity.java | ✅ 真实调用 |
| `GET /api/front/works/my/collected` | ProfileActivity.java | ✅ 真实调用 |
### 8. 文件上传模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `POST /api/front/user/upload/image` | PublishWorkActivity.java:642 | ✅ 真实调用 |
| `POST /api/front/upload/work/video` | PublishWorkActivity.java:691 | ✅ 真实调用 |
### 9. 在线状态模块 (80% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/online/status/{userId}` | ChatActivity.java | ✅ 真实调用 |
| `POST /api/front/online/status/batch` | MyFriendsActivity.java | ✅ 真实调用 |
| `GET /api/front/online/room/{roomId}/count` | RoomDetailActivity.java | ✅ 真实调用 |
| `GET /api/front/online/room/{roomId}/users` | RoomDetailActivity.java | ✅ 真实调用 |
**未调用接口**:
- ❌ `GET /api/front/online/stats` - 已定义但未使用(连接统计功能未实现)
### 10. 离线消息模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/online/offline/count/{userId}` | MessagesActivity.java | ✅ 真实调用 |
| `GET /api/front/online/offline/messages/{userId}` | MessagesActivity.java | ✅ 真实调用 |
| `DELETE /api/front/online/offline/messages/{userId}` | MessagesActivity.java | ✅ 真实调用 |
### 11. 搜索功能模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/search/live-rooms` | SearchActivity.java:156 | ✅ 真实调用 |
| `GET /api/front/search/hot` | SearchActivity.java:257 | ✅ 真实调用 |
| `GET /api/front/search/suggestions` | SearchActivity.java:286 | ✅ 真实调用 |
| `GET /api/front/search/history` | SearchActivity.java | ✅ 真实调用 |
| `DELETE /api/front/search/history` | SearchActivity.java | ✅ 真实调用 |
### 12. 观看历史模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `POST /api/front/watch/history` | RoomDetailActivity.java:744 | ✅ 真实调用 |
### 13. 分类管理模块 (100% 调用)
| 接口 | 调用位置 | 状态 |
|------|---------|------|
| `GET /api/front/category/live-room` | MainActivity.java | ✅ 真实调用 |
| `GET /api/front/category/work` | PublishWorkActivity.java | ✅ 真实调用 |
---
## ❌ 已定义但未真实调用的接口(共 8 个)
### 1. 直播间模块
```java
// ApiService.java 中已定义,但在代码中未找到调用
❌ GET /api/front/live/public/rooms/{roomId}/viewers/count
- 功能: 获取观众数量
- 原因: 使用 WebSocket 实时推送在线人数,不需要轮询
❌ GET /api/front/live/rooms/{roomId}/viewers
- 功能: 获取观众列表
- 原因: 功能未实现UI中没有显示观众列表的入口
```
### 2. 私聊模块
```java
❌ DELETE /api/front/conversations/messages/{id}
- 功能: 删除单条消息
- 原因: UI中只实现了删除整个会话未实现删除单条消息
```
### 3. 在线状态模块
```java
❌ GET /api/front/online/stats
- 功能: 获取连接统计信息
- 原因: 管理功能,前端应用不需要
```
### 4. 消息表情回应模块(整个模块未使用)
```java
❌ POST /api/front/messages/reactions/add
❌ DELETE /api/front/messages/reactions/remove
❌ GET /api/front/messages/{messageId}/reactions
❌ GET /api/front/messages/{messageId}/reactions/users
- 功能: 消息表情回应(类似微信的点赞、爱心等)
- 原因: 功能未实现UI设计中没有这个功能
```
### 5. 搜索模块
```java
❌ GET /api/front/search/users
- 功能: 全局搜索用户
- 原因: 使用了 /api/front/users/search 替代
❌ GET /api/front/search/works
- 功能: 全局搜索作品
- 原因: 使用了 /api/front/works/search 替代
❌ GET /api/front/search/all
- 功能: 综合搜索
- 原因: 功能未实现,当前只支持分类搜索
```
---
## 📈 统计数据
### 整体调用率
| 模块 | 已定义接口数 | 真实调用数 | 调用率 |
|------|------------|-----------|--------|
| 用户认证 | 5 | 5 | 100% |
| 直播间 | 12 | 10 | 83% |
| 礼物打赏 | 6 | 6 | 100% |
| 私聊会话 | 8 | 7 | 88% |
| 好友管理 | 9 | 9 | 100% |
| 关注功能 | 7 | 7 | 100% |
| 作品管理 | 12 | 12 | 100% |
| 文件上传 | 2 | 2 | 100% |
| 在线状态 | 5 | 4 | 80% |
| 离线消息 | 3 | 3 | 100% |
| 搜索功能 | 8 | 5 | 63% |
| 观看历史 | 1 | 1 | 100% |
| 分类管理 | 2 | 2 | 100% |
| 消息表情 | 4 | 0 | 0% |
| **总计** | **84** | **73** | **87%** |
### 关键发现
1. **高调用率模块** (100%):
- ✅ 用户认证、礼物打赏、好友管理、关注功能、作品管理
- ✅ 文件上传、离线消息、观看历史、分类管理
2. **中等调用率模块** (80-90%):
- ⚠️ 直播间模块 (83%) - 部分接口被 WebSocket 替代
- ⚠️ 私聊会话 (88%) - 删除单条消息功能未实现
- ⚠️ 在线状态 (80%) - 统计接口未使用
3. **低调用率模块** (< 80%):
- ⚠️ 搜索功能 (63%) - 部分接口有替代方案
- ❌ 消息表情 (0%) - 功能完全未实现
---
## 🔍 WebSocket 实时通信使用情况
### 已实现的 WebSocket 连接
| WebSocket | 用途 | 调用位置 | 状态 |
|-----------|------|---------|------|
| `ws://*/ws/live/chat/{roomId}` | 直播间弹幕 | RoomDetailActivity.java:connectChatWebSocket() | ✅ 真实使用 |
| `ws://*/ws/live/{roomId}` | 在线人数推送 | RoomDetailActivity.java:connectOnlineCountWebSocket() | ✅ 真实使用 |
| `ws://*/ws/private/chat` | 私聊消息 | ChatActivity.java:connectWebSocket() | ✅ 真实使用 |
| `ws://*/ws/online/status` | 在线状态推送 | MyFriendsActivity.java:connectWebSocket() | ✅ 真实使用 |
**WebSocket 特性**:
- ✅ 心跳检测机制 (30秒间隔)
- ✅ 自动重连机制 (最多5次)
- ✅ 连接状态管理
- ✅ 错误处理和降级
---
## 💡 结论与建议
### 结论
1. **整体调用率很高**: 87% 的接口都在真实使用中
2. **核心功能完整**: 用户认证、直播、私聊、好友、关注、作品等核心模块100%调用
3. **WebSocket 替代 HTTP**: 部分实时功能使用 WebSocket 替代了 HTTP 轮询
4. **未使用接口有原因**: 大部分未使用接口是因为功能未实现或有更好的替代方案
### 建议
#### 1. 可以删除的接口定义(降低维护成本)
```java
// ApiService.java 中可以删除以下接口定义
- GET /api/front/live/public/rooms/{roomId}/viewers/count (被 WebSocket 替代)
- GET /api/front/online/stats (前端不需要)
- 整个消息表情回应模块 (功能未实现)
```
#### 2. 建议实现的功能
```java
// 提升用户体验的功能
✨ DELETE /api/front/conversations/messages/{id}
- 实现删除单条消息功能
- 增加长按消息的操作菜单
✨ GET /api/front/search/all
- 实现综合搜索功能
- 一次搜索返回用户、直播间、作品等多种结果
```
#### 3. 代码优化建议
```java
// 统一搜索接口
- 将 /api/front/users/search 和 /api/front/search/users 合并
- 将 /api/front/works/search 和 /api/front/search/works 合并
```
---
## 📝 验证方法
本报告通过以下方法验证接口调用情况:
1. **静态代码分析**: 搜索 `apiService.` 关键字,找到所有 Retrofit 接口调用
2. **文件定位**: 记录每个接口调用的具体文件和行号
3. **对比分析**: 将 ApiService.java 中定义的接口与实际调用进行对比
4. **WebSocket 分析**: 检查 WebSocket 连接代码,确认实时通信功能
5. **交叉验证**: 检查后端 Controller 代码,确认接口实现情况
---
**报告生成时间**: 2024-12-30
**分析工具**: 代码静态分析 + 人工审查
**可信度**: ⭐⭐⭐⭐⭐ (非常高)

View File

@ -9,7 +9,7 @@
// +----------------------------------------------------------------------
// 请求接口地址 如果没有配置自动获取当前网址路径
const VUE_APP_API_URL = process.env.VUE_APP_BASE_API || '/api';
const VUE_APP_API_URL = 'http://127.0.0.1:30001';
module.exports = {
// 接口请求地址
apiBaseURL: VUE_APP_API_URL,

View File

@ -9,7 +9,7 @@
// +----------------------------------------------------------------------
// 请求接口地址 如果没有配置自动获取当前网址路径
const VUE_APP_API_URL = process.env.VUE_APP_BASE_API || '/api';
const VUE_APP_API_URL = 'http://127.0.0.1:30001';
const VUE_APP_WS_URL =
process.env.VUE_APP_WS_URL || (location.protocol === 'https' ? 'wss' : 'ws') + ':' + location.hostname;
const SettingMer = {

View File

@ -125,7 +125,7 @@ logging:
# mybatis 配置
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml #xml扫描多个目录用逗号或者分号分隔告诉 Mapper 所对应的 XML 文件位置)
mapper-locations: classpath*:mapper/*/*Mapper.xml #xml扫描多个目录用逗号或者分号分隔告诉 Mapper 所对应的 XML 文件位置)
typeAliasesPackage: com.zbkj.**.model
# 配置slq打印日志
configuration:

View File

@ -41,4 +41,8 @@ public class LoginRequest implements Serializable {
@ApiModelProperty(value = "密码", required = true, example = "1~[6,18]")
// @Pattern(regexp = RegularConstants.PASSWORD, message = "密码格式错误密码必须以字母开头长度在6~18之间只能包含字符、数字和下划线")
private String password;
@ApiModelProperty(value = "推广人id")
@JsonProperty(value = "spread_spid")
private Integer spreadPid = 0;
}

View File

@ -229,3 +229,4 @@ public class CallController {
return response;
}
}

View File

@ -288,7 +288,7 @@ public class LiveRoomController {
}
}
// ========== 直播控制接口 ==========
// ========== 关注主播接口 ==========
@ApiOperation(value = "开始直播")
@PostMapping("/room/{id}/start")

View File

@ -84,6 +84,11 @@ public class LoginServiceImpl implements LoginService {
String token = tokenComponent.createToken(user);
loginResponse.setToken(token);
//绑定推广关系
if (loginRequest.getSpreadPid() > 0) {
bindSpread(user, loginRequest.getSpreadPid());
}
// 记录最后一次登录时间
user.setLastLoginTime(CrmebDateUtil.nowDateTime());
user.setUpdateTime(DateUtil.date());

View File

@ -53,8 +53,6 @@ public class CallSignalingHandler extends TextWebSocketHandler {
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
System.out.println("[CallSignaling] ========== 连接建立 ==========");
System.out.println("[CallSignaling] sessionId=" + session.getId());
logger.info("[CallSignaling] 连接建立: sessionId={}", session.getId());
}
@ -65,9 +63,6 @@ public class CallSignalingHandler extends TextWebSocketHandler {
String type = json.has("type") ? json.get("type").asText() : "";
switch (type) {
case "ping":
handlePing(session);
break;
case "register":
handleRegister(session, json);
break;
@ -102,9 +97,6 @@ public class CallSignalingHandler extends TextWebSocketHandler {
private void handleRegister(WebSocketSession session, JsonNode json) throws IOException {
Integer userId = json.has("userId") ? json.get("userId").asInt() : null;
System.out.println("[CallSignaling] ========== 用户注册 ==========");
System.out.println("[CallSignaling] userId=" + userId);
if (userId == null) {
sendError(session, "userId不能为空");
return;
@ -113,7 +105,6 @@ public class CallSignalingHandler extends TextWebSocketHandler {
// 关闭旧连接
WebSocketSession oldSession = userCallSessions.get(userId);
if (oldSession != null && oldSession.isOpen() && !oldSession.getId().equals(session.getId())) {
System.out.println("[CallSignaling] 关闭旧连接: userId=" + userId);
logger.info("[CallSignaling] 关闭旧连接: userId={}, oldSessionId={}", userId, oldSession.getId());
try {
oldSession.close();
@ -127,8 +118,6 @@ public class CallSignalingHandler extends TextWebSocketHandler {
response.put("type", "registered");
response.put("userId", userId);
session.sendMessage(new TextMessage(response.toString()));
System.out.println("[CallSignaling] 用户注册成功: userId=" + userId + ", 当前在线用户=" + userCallSessions.keySet());
logger.info("[CallSignaling] 用户注册成功: userId={}, sessionId={}, 当前在线用户数={}",
userId, session.getId(), userCallSessions.size());
}
@ -339,38 +328,15 @@ public class CallSignalingHandler extends TextWebSocketHandler {
private void handleSignaling(WebSocketSession session, JsonNode json, String type) throws IOException {
String callId = json.has("callId") ? json.get("callId").asText() : sessionCallMap.get(session.getId());
Integer senderId = sessionUserMap.get(session.getId());
System.out.println("[CallSignaling] ========== 处理信令消息 ==========");
System.out.println("[CallSignaling] type=" + type + ", callId=" + callId + ", senderId=" + senderId);
System.out.println("[CallSignaling] senderSessionId=" + session.getId());
if (callId == null) {
sendError(session, "callId不能为空");
return;
}
// 确保通话会话存在
Set<WebSocketSession> sessions = callSessions.get(callId);
if (sessions == null) {
// 尝试创建会话可能是REST API发起的通话
System.out.println("[CallSignaling] 通话会话不存在,创建新会话: callId=" + callId);
logger.warn("[CallSignaling] 通话会话不存在,尝试创建: callId={}", callId);
sessions = callSessions.computeIfAbsent(callId, k -> new CopyOnWriteArraySet<>());
}
System.out.println("[CallSignaling] 当前会话中的参与者数量: " + sessions.size());
for (WebSocketSession s : sessions) {
Integer userId = sessionUserMap.get(s.getId());
System.out.println("[CallSignaling] - sessionId=" + s.getId() + ", userId=" + userId + ", isOpen=" + s.isOpen());
}
// 确保当前用户在通话会话中
if (!sessions.contains(session)) {
sessions.add(session);
sessionCallMap.put(session.getId(), callId);
System.out.println("[CallSignaling] 将发送者加入通话会话: sessionId=" + session.getId() + ", userId=" + senderId);
logger.info("[CallSignaling] 将用户加入通话会话: callId={}, sessionId={}", callId, session.getId());
sendError(session, "通话不存在");
return;
}
// 转发信令给通话中的其他参与者
@ -385,22 +351,13 @@ public class CallSignalingHandler extends TextWebSocketHandler {
}
String forwardMsg = forward.toString();
int forwardCount = 0;
for (WebSocketSession s : sessions) {
Integer targetUserId = sessionUserMap.get(s.getId());
boolean isSender = s.getId().equals(session.getId());
System.out.println("[CallSignaling] 检查转发目标: targetSessionId=" + s.getId() +
", targetUserId=" + targetUserId + ", isOpen=" + s.isOpen() + ", isSender=" + isSender);
if (s.isOpen() && !isSender) {
if (s.isOpen() && !s.getId().equals(session.getId())) {
s.sendMessage(new TextMessage(forwardMsg));
forwardCount++;
System.out.println("[CallSignaling] >>> 已转发给: userId=" + targetUserId);
}
}
System.out.println("[CallSignaling] 转发完成: type=" + type + ", 转发给" + forwardCount + "个用户");
logger.info("[CallSignaling] 转发信令: type={}, callId={}, senderId={}, 转发给{}个用户", type, callId, senderId, forwardCount);
logger.debug("[CallSignaling] 转发信令: type={}, callId={}", type, callId);
}
@Override
@ -504,49 +461,11 @@ public class CallSignalingHandler extends TextWebSocketHandler {
return "calling".equals(status) || "ringing".equals(status) || "connected".equals(status);
}
/**
* 处理心跳 ping 消息
*/
private void handlePing(WebSocketSession session) {
try {
if (session != null && session.isOpen()) {
ObjectNode pong = objectMapper.createObjectNode();
pong.put("type", "pong");
session.sendMessage(new TextMessage(pong.toString()));
}
} catch (Exception e) {
logger.warn("[CallSignaling] 发送 pong 失败: {}", e.getMessage());
}
}
private void sendError(WebSocketSession session, String message) {
try {
if (session != null && session.isOpen()) {
synchronized (session) {
ObjectNode error = objectMapper.createObjectNode();
error.put("type", "error");
error.put("message", message);
session.sendMessage(new TextMessage(error.toString()));
}
}
} catch (Exception e) {
logger.warn("[CallSignaling] 发送错误消息失败: {}", e.getMessage());
}
}
/**
* 安全发送消息带同步锁
*/
private void sendMessage(WebSocketSession session, String message) {
try {
if (session != null && session.isOpen()) {
synchronized (session) {
session.sendMessage(new TextMessage(message));
}
}
} catch (Exception e) {
logger.warn("[CallSignaling] 发送消息失败: {}", e.getMessage());
}
private void sendError(WebSocketSession session, String message) throws IOException {
ObjectNode error = objectMapper.createObjectNode();
error.put("type", "error");
error.put("message", message);
session.sendMessage(new TextMessage(error.toString()));
}
/**
@ -554,27 +473,9 @@ public class CallSignalingHandler extends TextWebSocketHandler {
*/
public void notifyIncomingCall(String callId, Integer callerId, String callerName, String callerAvatar,
Integer calleeId, String callType) {
System.out.println("[CallSignaling] ========== 通知来电 ==========");
System.out.println("[CallSignaling] callId=" + callId + ", callerId=" + callerId + ", calleeId=" + calleeId);
System.out.println("[CallSignaling] 当前在线用户=" + userCallSessions.keySet());
try {
// 记录通话创建时间
callCreateTime.put(callId, System.currentTimeMillis());
// 初始化通话会话重要这样后续的信令才能正确转发
callSessions.computeIfAbsent(callId, k -> new CopyOnWriteArraySet<>());
// 将主叫方加入通话会话
WebSocketSession callerSession = userCallSessions.get(callerId);
if (callerSession != null && callerSession.isOpen()) {
joinCallSession(callId, callerSession);
sessionCallMap.put(callerSession.getId(), callId);
System.out.println("[CallSignaling] 主叫方加入通话会话: callerId=" + callerId);
logger.info("[CallSignaling] 主叫方加入通话会话: callId={}, callerId={}", callId, callerId);
} else {
System.out.println("[CallSignaling] 主叫方未在线: callerId=" + callerId);
}
// 通知被叫方
WebSocketSession calleeSession = userCallSessions.get(calleeId);
@ -587,14 +488,11 @@ public class CallSignalingHandler extends TextWebSocketHandler {
incoming.put("callerAvatar", callerAvatar != null ? callerAvatar : "");
incoming.put("callType", callType);
calleeSession.sendMessage(new TextMessage(incoming.toString()));
System.out.println("[CallSignaling] 已发送来电通知给被叫方: calleeId=" + calleeId);
logger.info("[CallSignaling] 通知来电: callId={}, callee={}", callId, calleeId);
} else {
System.out.println("[CallSignaling] !!!!! 被叫方未在线 !!!!! calleeId=" + calleeId);
logger.warn("[CallSignaling] 被叫方未在线: calleeId={}, 当前在线用户={}", calleeId, userCallSessions.keySet());
logger.warn("[CallSignaling] 被叫方未在线: calleeId={}", calleeId);
}
} catch (Exception e) {
System.out.println("[CallSignaling] 通知来电异常: " + e.getMessage());
logger.error("[CallSignaling] 通知来电异常: callId={}", callId, e);
}
}
@ -614,15 +512,8 @@ public class CallSignalingHandler extends TextWebSocketHandler {
logger.info("[CallSignaling] REST API调用notifyCallAccepted: callId={}, callerId={}, 当前在线用户={}",
callId, callerId, userCallSessions.keySet());
try {
// 确保通话会话存在
callSessions.computeIfAbsent(callId, k -> new CopyOnWriteArraySet<>());
WebSocketSession callerSession = userCallSessions.get(callerId);
if (callerSession != null && callerSession.isOpen()) {
// 确保主叫方在通话会话中
joinCallSession(callId, callerSession);
sessionCallMap.put(callerSession.getId(), callId);
ObjectNode notify = objectMapper.createObjectNode();
notify.put("type", "call_accepted");
notify.put("callId", callId);

View File

@ -84,8 +84,7 @@ debug: true
logging:
level:
io.swagger.*: error
com.zbkj: debug
com.zbkj.front.websocket: info
com.zbjk.crmeb: debug
org.springframework.boot.autoconfigure: ERROR
config: classpath:logback-spring.xml
file:
@ -93,7 +92,7 @@ logging:
# mybatis 配置
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml #xml扫描多个目录用逗号或者分号分隔告诉 Mapper 所对应的 XML 文件位置)
mapper-locations: classpath*:mapper/*/*Mapper.xml #xml扫描多个目录用逗号或者分号分隔告诉 Mapper 所对应的 XML 文件位置)
# 配置sql打印日志
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

View File

@ -113,8 +113,4 @@ dependencies {
implementation("com.github.andnux:ijkplayer:0.0.1") {
exclude("com.google.android.exoplayer", "exoplayer")
}
// WebRTC for voice/video calls
// 使用 Google 官方 WebRTC 库
implementation("io.getstream:stream-webrtc-android:1.1.1")
}

View File

@ -12,13 +12,6 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Android 10+ 需要此权限来显示来电全屏界面 -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- 前台服务权限,用于保持通话连接 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<!-- 系统警报窗口权限,用于在锁屏上显示来电 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application
android:name=".LiveStreamingApplication"

View File

@ -183,11 +183,41 @@ public class CategoryFilterManager {
String roomType = r.getType();
if (c.equals(roomType)) {
filtered.add(r);
continue;
}
// 降级到演示数据分类算法
String demoCategory = getDemoCategoryForRoom(r);
if (c.equals(demoCategory)) {
filtered.add(r);
}
}
return filtered;
}
/**
* 获取房间的演示分类用于降级处理
*/
private String getDemoCategoryForRoom(Room room) {
if (room == null) return "推荐";
String title = room.getTitle() != null ? room.getTitle() : "";
String streamer = room.getStreamerName() != null ? room.getStreamerName() : "";
// 简单的分类逻辑可以根据实际需求调整
if (title.contains("游戏") || streamer.contains("游戏")) {
return "游戏";
}
if (title.contains("音乐") || streamer.contains("音乐")) {
return "音乐";
}
if (title.contains("聊天") || streamer.contains("聊天")) {
return "聊天";
}
if (title.contains("才艺") || streamer.contains("才艺")) {
return "才艺";
}
return "推荐";
}
/**
* 保存最后选中的分类
*/

View File

@ -51,7 +51,11 @@ public class LikesListActivity extends AppCompatActivity {
private List<ConversationItem> buildDemoLikes() {
List<ConversationItem> list = new ArrayList<>();
// 不再使用模拟数据只从后端接口获取真实点赞数据
list.add(new ConversationItem("l1", "小雨", "赞了你的直播间", "09:12", 0, false));
list.add(new ConversationItem("l2", "阿宁", "赞了你的作品", "昨天", 0, false));
list.add(new ConversationItem("l3", "小星", "赞了你", "周二", 0, false));
list.add(new ConversationItem("l4", "小林", "赞了你的直播回放", "上周", 0, false));
list.add(new ConversationItem("l5", "阿杰", "赞了你的作品", "上周", 0, false));
return list;
}
}

View File

@ -44,9 +44,7 @@ import com.google.android.material.textfield.MaterialAutoCompleteTextView;
import com.google.android.material.textfield.TextInputLayout;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ConversationResponse;
import com.example.livestreaming.net.CreateRoomRequest;
import com.example.livestreaming.net.PageResponse;
import com.example.livestreaming.net.Room;
import com.example.livestreaming.net.StreamConfig;
@ -54,7 +52,6 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
@ -99,7 +96,17 @@ public class MainActivity extends AppCompatActivity {
// 用户打开APP时不需要强制登录可以直接使用APP
// 只有在使用需要登录的功能时如加好友发送弹幕等才检查登录状态
// 登录和注册功能已在LoginActivity中实现
// TODO: 接入后端接口 - 用户登录
// 接口路径: POST /api/front/loginApiService中已定义
// 请求参数: LoginRequest {account: string, password: string}
// 返回数据格式: ApiResponse<LoginResponse>
// LoginResponse对象应包含: token, userId, nickname, avatarUrl等字段
// 登录成功后保存token到AuthStore并更新用户信息
// TODO: 接入后端接口 - 用户注册
// 接口路径: POST /api/front/registerApiService中已定义
// 请求参数: RegisterRequest {phone: string, password: string, verificationCode: string, nickname: string}
// 返回数据格式: ApiResponse<LoginResponse>
// 注册成功后自动登录并保存token
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
@ -113,10 +120,15 @@ public class MainActivity extends AppCompatActivity {
loadAvatarFromPrefs();
setupSpeechRecognizer();
// 初始化未读消息数量
// TODO: 接入后端接口 - 获取未读消息总数
// 接口路径: GET /api/messages/unread/count
// 请求参数: 从token中获取userId
// 返回数据格式: ApiResponse<Integer> ApiResponse<{unreadCount: number}>
// 返回当前用户所有会话的未读消息总数
// 初始化未读消息数量演示数据
if (UnreadMessageManager.getUnreadCount(this) == 0) {
// 从会话列表获取总未读数量
fetchUnreadMessageCount();
// 消息列表计算总未读数量
UnreadMessageManager.setUnreadCount(this, calculateTotalUnreadCount());
}
// 初始化顶部标签页数据
@ -535,7 +547,15 @@ public class MainActivity extends AppCompatActivity {
// 如果文本为空启动语音识别
startVoiceRecognition();
} else {
// 如果文本不为空跳转到搜索页面
// 如果文本不为空执行搜索
// TODO: 接入后端接口 - 搜索功能
// 接口路径: GET /api/search
// 请求参数:
// - keyword: 搜索关键词
// - type (可选): 搜索类型room/user/all
// - page (可选): 页码
// 返回数据格式: ApiResponse<{rooms: Room[], users: User[]}>
// 跳转到搜索页面并传递搜索关键词
SearchActivity.start(MainActivity.this, searchText);
}
});
@ -821,48 +841,20 @@ public class MainActivity extends AppCompatActivity {
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNavigation);
}
// 确保通话信令 WebSocket 保持连接用于接收来电通知
LiveStreamingApplication app = (LiveStreamingApplication) getApplication();
app.connectCallSignalingIfLoggedIn();
}
/**
* 从后端获取未读消息总数
* 计算总未读消息数量从演示数据中计算
*/
private void fetchUnreadMessageCount() {
// 检查登录状态
if (!AuthHelper.isLoggedIn(this)) {
return;
}
// 从会话列表接口获取未读消息总数
ApiClient.getService(getApplicationContext()).getConversations()
.enqueue(new Callback<ApiResponse<List<ConversationResponse>>>() {
@Override
public void onResponse(Call<ApiResponse<List<ConversationResponse>>> call,
Response<ApiResponse<List<ConversationResponse>>> response) {
ApiResponse<List<ConversationResponse>> body = response.body();
if (response.isSuccessful() && body != null && body.isOk() && body.getData() != null) {
int totalUnread = 0;
for (ConversationResponse conv : body.getData()) {
if (conv != null && conv.getUnreadCount() != null) {
totalUnread += conv.getUnreadCount();
}
}
UnreadMessageManager.setUnreadCount(MainActivity.this, totalUnread);
// 更新底部导航栏徽章
if (binding != null && binding.bottomNavInclude != null) {
UnreadMessageManager.updateBadge(binding.bottomNavInclude.bottomNavigation);
}
}
}
@Override
public void onFailure(Call<ApiResponse<List<ConversationResponse>>> call, Throwable t) {
// 网络错误忽略
}
});
private int calculateTotalUnreadCount() {
// 模拟从消息列表计算总未读数量
// 这里使用 MessagesActivity 中的演示数据
int total = 0;
total += 2; // 系统通知
total += 5; // 附近的人
total += 19; // 直播间群聊
total += 1; // 客服
return total;
}
@Override
@ -978,7 +970,14 @@ public class MainActivity extends AppCompatActivity {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
// 调用后端接口创建直播间
// TODO: 接入后端接口 - 创建直播间
// 接口路径: POST /api/rooms
// 请求参数: CreateRoomRequest
// - title: 直播间标题
// - streamerName: 主播名称
// - type: 直播类型"live"
// 返回数据格式: ApiResponse<Room>
// Room对象应包含: id, title, streamerName, streamKey, streamUrls (包含rtmp, flv, hls地址)等字段
ApiClient.getService(getApplicationContext()).createRoom(new CreateRoomRequest(title, streamerName, "live"))
.enqueue(new Callback<ApiResponse<Room>>() {
@Override
@ -1317,6 +1316,10 @@ public class MainActivity extends AppCompatActivity {
String roomType = r.getType();
if (c.equals(roomType)) {
filtered.add(r);
continue;
}
if (c.equals(getDemoCategoryForRoom(r))) {
filtered.add(r);
}
}
@ -1416,7 +1419,70 @@ public class MainActivity extends AppCompatActivity {
}
}
private String getDemoCategoryForRoom(Room room) {
String[] categories = new String[]{"游戏", "才艺", "户外", "音乐", "美食", "聊天"};
try {
String seed = room != null && room.getId() != null ? room.getId() : room != null ? room.getTitle() : "";
int h = Math.abs(seed != null ? seed.hashCode() : 0);
return categories[h % categories.length];
} catch (Exception ignored) {
return "游戏";
}
}
private List<Room> buildDemoRooms(int count) {
List<Room> list = new ArrayList<>();
// 预定义的演示数据包含不同类型的直播内容
String[][] demoData = {
{"王者荣耀排位赛", "小明选手", "游戏", "true"},
{"吃鸡大逃杀", "游戏高手", "游戏", "true"},
{"唱歌连麦", "音乐达人", "音乐", "true"},
{"户外直播", "旅行者", "户外", "false"},
{"美食制作", "厨神小李", "美食", "true"},
{"才艺表演", "舞蹈小妹", "才艺", "true"},
{"聊天交友", "暖心姐姐", "聊天", "false"},
{"LOL竞技场", "电竞选手", "游戏", "true"},
{"古风演奏", "琴师小王", "音乐", "true"},
{"健身教学", "教练张", "户外", "false"},
{"摄影分享", "摄影师", "户外", "true"},
{"宠物秀", "萌宠主播", "才艺", "true"},
{"编程教学", "码农老王", "聊天", "false"},
{"读书分享", "书虫小妹", "聊天", "true"},
{"手工制作", "手艺人", "才艺", "true"},
{"英语口语", "外教老师", "聊天", "false"},
{"魔术表演", "魔术师", "才艺", "true"},
{"街头访谈", "记者小张", "户外", "true"},
{"乐器教学", "音乐老师", "音乐", "false"},
{"电影解说", "影评人", "聊天", "true"}
};
for (int i = 0; i < count && i < demoData.length; i++) {
String id = "demo-" + i;
String title = demoData[i][0];
String streamer = demoData[i][1];
String type = demoData[i][2];
boolean live = Boolean.parseBoolean(demoData[i][3]);
Room room = new Room(id, title, streamer, live);
room.setType(type);
list.add(room);
}
// 如果需要更多数据继续生成
String[] categories = new String[]{"游戏", "才艺", "户外", "音乐", "美食", "聊天"};
for (int i = demoData.length; i < count; i++) {
String id = "demo-" + i;
String title = "直播房间" + (i + 1);
String streamer = "主播" + (i + 1);
String type = categories[i % categories.length];
boolean live = i % 3 != 0;
Room room = new Room(id, title, streamer, live);
room.setType(type);
list.add(room);
}
return list;
}
/**
* 从后端加载分类数据
@ -1539,17 +1605,24 @@ public class MainActivity extends AppCompatActivity {
/**
* 初始化顶部标签页数据
* 顶部标签页关注/发现/附近为固定配置不需要从后端动态获取
* TODO: 接入后端接口 - 获取顶部标签页配置关注/发现/附近
* 接口路径: GET /api/home/tabs
* 请求参数: 从token中获取userId可选
* 返回数据格式: ApiResponse<List<TabConfig>>
* TabConfig对象应包含: id, name, iconUrl, badgeCount未读数等等字段
* 用于动态配置顶部标签页支持个性化显示
*/
private void initializeTopTabData() {
// 初始化关注页面数据
// 初始化关注页面数据已关注主播的直播- 使用演示数据
followRooms.clear();
followRooms.addAll(buildFollowRooms());
// 初始化发现页面数据 - 从后端获取真实直播间
fetchDiscoverRooms();
// 初始化附近页面数据
// 初始化附近页面数据模拟位置数据
nearbyUsers.clear();
nearbyUsers.addAll(buildNearbyUsers());
}
/**
@ -1770,77 +1843,45 @@ public class MainActivity extends AppCompatActivity {
}
/**
* 显示关注页面时从后端获取关注主播的直播间列表
* 注意关注功能需要用户登录在showFollowTab()中会检查登录状态
* 构建关注页面的房间列表已关注主播的直播
*/
private void fetchFollowRooms() {
// 检查登录状态
if (!AuthHelper.isLoggedIn(this)) {
followRooms.clear();
if (adapter != null) {
adapter.submitList(new ArrayList<>());
}
return;
private List<Room> buildFollowRooms() {
// TODO: 接入后端接口 - 获取关注主播的直播间列表
// 接口路径: GET /api/following/rooms GET /api/rooms?type=following
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<Room>>
// Room对象应包含: id, title, streamerName, streamerId, type, isLive, coverUrl, viewerCount等字段
// 只返回当前用户已关注的主播正在直播的房间
List<Room> list = new ArrayList<>();
// 从FollowingListActivity获取已关注的主播列表
// 这里使用模拟数据实际应该从数据库或API获取
String[][] followData = {
{"王者荣耀排位赛", "王者荣耀陪练", "游戏", "true"},
{"音乐电台", "音乐电台", "音乐", "false"},
{"户外直播", "户外阿杰", "户外", "true"},
{"美食探店", "美食探店", "美食", "false"},
{"聊天连麦", "聊天小七", "聊天", "true"},
{"才艺表演", "才艺小妹", "才艺", "true"},
{"游戏竞技", "游戏高手", "游戏", "true"},
{"音乐演奏", "音乐达人", "音乐", "false"}
};
for (int i = 0; i < followData.length; i++) {
String id = "follow-" + i;
String title = followData[i][0];
String streamer = followData[i][1];
String type = followData[i][2];
boolean live = Boolean.parseBoolean(followData[i][3]);
Room room = new Room(id, title, streamer, live);
room.setType(type);
list.add(room);
}
// 显示加载状态
if (binding.loading != null) {
binding.loading.setVisibility(View.VISIBLE);
}
// 从关注列表获取关注的用户ID然后筛选出正在直播的房间
ApiClient.getService(getApplicationContext()).getFollowingList(1, 100)
.enqueue(new Callback<ApiResponse<PageResponse<Map<String, Object>>>>() {
@Override
public void onResponse(Call<ApiResponse<PageResponse<Map<String, Object>>>> call,
Response<ApiResponse<PageResponse<Map<String, Object>>>> response) {
if (binding.loading != null) {
binding.loading.setVisibility(View.GONE);
}
ApiResponse<PageResponse<Map<String, Object>>> body = response.body();
if (response.isSuccessful() && body != null && body.isOk() && body.getData() != null) {
List<Map<String, Object>> followingList = body.getData().getList();
if (followingList != null && !followingList.isEmpty()) {
// 获取所有直播间然后筛选出关注用户的直播间
fetchAndFilterFollowRooms(followingList);
} else {
followRooms.clear();
if ("关注".equals(currentTopTab) && adapter != null) {
adapter.submitList(new ArrayList<>());
}
}
}
}
@Override
public void onFailure(Call<ApiResponse<PageResponse<Map<String, Object>>>> call, Throwable t) {
if (binding.loading != null) {
binding.loading.setVisibility(View.GONE);
}
followRooms.clear();
if ("关注".equals(currentTopTab) && adapter != null) {
adapter.submitList(new ArrayList<>());
}
}
});
}
/**
* 获取所有直播间并筛选出关注用户的直播间
*/
private void fetchAndFilterFollowRooms(List<Map<String, Object>> followList) {
// 从关注列表中提取用户ID
if (followList == null || followList.isEmpty()) {
if (adapter != null) {
adapter.submitList(new ArrayList<>());
}
return;
}
// TODO: 实现根据关注列表筛选直播间
if (adapter != null) {
adapter.submitList(new ArrayList<>());
}
return list;
}
/**
@ -1858,7 +1899,62 @@ public class MainActivity extends AppCompatActivity {
// 后端应根据用户观看历史点赞记录关注关系等进行个性化推荐
List<Room> list = new ArrayList<>();
// 不再使用模拟数据只从后端接口获取真实推荐直播间数据
// 推荐算法基于观看历史点赞等模拟数据
// 这里实现一个简单的推荐算法
// 1. 优先推荐正在直播的房间
// 2. 优先推荐热门类型游戏才艺音乐
// 3. 添加一些随机性
String[][] discoverData = {
{"王者荣耀排位赛", "小明选手", "游戏", "true"},
{"吃鸡大逃杀", "游戏高手", "游戏", "true"},
{"唱歌连麦", "音乐达人", "音乐", "true"},
{"户外直播", "旅行者", "户外", "false"},
{"美食制作", "厨神小李", "美食", "true"},
{"才艺表演", "舞蹈小妹", "才艺", "true"},
{"聊天交友", "暖心姐姐", "聊天", "false"},
{"LOL竞技场", "电竞选手", "游戏", "true"},
{"古风演奏", "琴师小王", "音乐", "true"},
{"健身教学", "教练张", "户外", "false"},
{"摄影分享", "摄影师", "户外", "true"},
{"宠物秀", "萌宠主播", "才艺", "true"},
{"编程教学", "码农老王", "聊天", "false"},
{"读书分享", "书虫小妹", "聊天", "true"},
{"手工制作", "手艺人", "才艺", "true"},
{"英语口语", "外教老师", "聊天", "false"},
{"魔术表演", "魔术师", "才艺", "true"},
{"街头访谈", "记者小张", "户外", "true"},
{"乐器教学", "音乐老师", "音乐", "false"},
{"电影解说", "影评人", "聊天", "true"},
{"游戏攻略", "游戏解说", "游戏", "true"},
{"K歌大赛", "K歌达人", "音乐", "true"},
{"美食探店", "美食博主", "美食", "true"},
{"舞蹈教学", "舞蹈老师", "才艺", "true"}
};
// 推荐算法优先显示正在直播的然后按类型排序
List<Room> liveRooms = new ArrayList<>();
List<Room> offlineRooms = new ArrayList<>();
for (int i = 0; i < discoverData.length; i++) {
String id = "discover-" + i;
String title = discoverData[i][0];
String streamer = discoverData[i][1];
String type = discoverData[i][2];
boolean live = Boolean.parseBoolean(discoverData[i][3]);
Room room = new Room(id, title, streamer, live);
room.setType(type);
if (live) {
liveRooms.add(room);
} else {
offlineRooms.add(room);
}
}
// 先添加正在直播的再添加未直播的
list.addAll(liveRooms);
list.addAll(offlineRooms);
return list;
}
@ -1880,7 +1976,28 @@ public class MainActivity extends AppCompatActivity {
// 需要先获取用户位置权限然后调用此接口
List<NearbyUser> list = new ArrayList<>();
// 不再使用模拟数据只从后端接口获取真实附近用户数据
// 模拟位置数据生成不同距离的用户
String[] names = {"小王", "小李", "安安", "小陈", "小美", "老张", "小七", "阿杰",
"小雨", "阿宁", "小星", "小林", "小杨", "小刘", "小赵", "小孙", "小周", "小吴"};
for (int i = 0; i < names.length; i++) {
String id = "nearby-user-" + i;
String name = names[i];
boolean live = i % 3 == 0; // 每3个用户中有一个在直播
String distanceText;
if (i < 3) {
distanceText = (300 + i * 120) + "m";
} else if (i < 10) {
float km = 0.8f + (i - 3) * 0.35f;
distanceText = String.format("%.1fkm", km);
} else {
float km = 3.5f + (i - 10) * 0.5f;
distanceText = String.format("%.1fkm", km);
}
list.add(new NearbyUser(id, name, distanceText, live));
}
return list;
}

View File

@ -75,8 +75,9 @@ public class MessageSendHelper {
// 3. 调用接口
// 4. 处理响应
// 临时模拟成功
if (callback != null) {
callback.onError("消息发送功能待接入后端接口");
callback.onSuccess("temp_message_id_" + System.currentTimeMillis());
}
}
@ -149,8 +150,9 @@ public class MessageSendHelper {
// 5. 上传图片
// 6. 处理响应
// 临时模拟成功
if (callback != null) {
callback.onError("图片消息发送功能待接入后端接口");
callback.onSuccess("temp_image_message_id_" + System.currentTimeMillis());
}
}
@ -222,8 +224,9 @@ public class MessageSendHelper {
// 4. 上传语音
// 5. 处理响应
// 临时模拟成功
if (callback != null) {
callback.onError("语音消息发送功能待接入后端接口");
callback.onSuccess("temp_voice_message_id_" + System.currentTimeMillis());
}
}

View File

@ -704,7 +704,13 @@ public class MessagesActivity extends AppCompatActivity {
private List<ConversationItem> buildDemoConversations() {
List<ConversationItem> list = new ArrayList<>();
// 不再使用模拟数据只从后端接口获取真实会话数据
list.add(new ConversationItem("sys", "系统通知", "欢迎来到直播间~新手指南已送达", "09:12", 2, false));
list.add(new ConversationItem("a", "小王(主播)", "今晚8点开播记得来捧场", "昨天", 0, false));
list.add(new ConversationItem("b", "附近的人", "嗨~一起连麦吗?", "昨天", 5, false));
list.add(new ConversationItem("c", "运营小助手", "活动报名已通过,点击查看详情", "周二", 0, true));
list.add(new ConversationItem("d", "直播间群聊", "[图片]", "周一", 19, false));
list.add(new ConversationItem("e", "小李", "收到啦", "周一", 0, false));
list.add(new ConversationItem("f", "客服", "您好,请描述一下遇到的问题", "上周", 1, false));
return list;
}

View File

@ -1,61 +0,0 @@
package com.example.livestreaming;
import java.io.Serializable;
public class Post implements Serializable {
private String id;
private String userId;
private String userName;
private String userAvatar;
private String content;
private String imageUrl;
private String category;
private long timestamp;
private int likeCount;
private int commentCount;
private boolean isLiked;
public Post() {}
public Post(String id, String userId, String userName, String content, String category) {
this.id = id;
this.userId = userId;
this.userName = userName;
this.content = content;
this.category = category;
this.timestamp = System.currentTimeMillis();
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public String getUserAvatar() { return userAvatar; }
public void setUserAvatar(String userAvatar) { this.userAvatar = userAvatar; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public long getTimestamp() { return timestamp; }
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
public int getLikeCount() { return likeCount; }
public void setLikeCount(int likeCount) { this.likeCount = likeCount; }
public int getCommentCount() { return commentCount; }
public void setCommentCount(int commentCount) { this.commentCount = commentCount; }
public boolean isLiked() { return isLiked; }
public void setLiked(boolean liked) { isLiked = liked; }
}

View File

@ -1,80 +0,0 @@
package com.example.livestreaming;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class PostAdapter extends RecyclerView.Adapter<PostAdapter.PostViewHolder> {
private List<Post> posts = new ArrayList<>();
public void setPosts(List<Post> posts) {
this.posts = posts != null ? posts : new ArrayList<>();
notifyDataSetChanged();
}
public void addPost(Post post) {
if (post != null) {
posts.add(0, post);
notifyItemInserted(0);
}
}
@NonNull
@Override
public PostViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_post, parent, false);
return new PostViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull PostViewHolder holder, int position) {
Post post = posts.get(position);
holder.bind(post);
}
@Override
public int getItemCount() {
return posts.size();
}
static class PostViewHolder extends RecyclerView.ViewHolder {
private final TextView tvUserName;
private final TextView tvContent;
private final TextView tvTime;
private final TextView tvLikeCount;
private final TextView tvCommentCount;
public PostViewHolder(@NonNull View itemView) {
super(itemView);
tvUserName = itemView.findViewById(R.id.tv_user_name);
tvContent = itemView.findViewById(R.id.tv_content);
tvTime = itemView.findViewById(R.id.tv_time);
tvLikeCount = itemView.findViewById(R.id.tv_like_count);
tvCommentCount = itemView.findViewById(R.id.tv_comment_count);
}
public void bind(Post post) {
if (tvUserName != null) tvUserName.setText(post.getUserName());
if (tvContent != null) tvContent.setText(post.getContent());
if (tvTime != null) {
SimpleDateFormat sdf = new SimpleDateFormat("MM-dd HH:mm", Locale.getDefault());
tvTime.setText(sdf.format(new Date(post.getTimestamp())));
}
if (tvLikeCount != null) tvLikeCount.setText(String.valueOf(post.getLikeCount()));
if (tvCommentCount != null) tvCommentCount.setText(String.valueOf(post.getCommentCount()));
}
}
}

View File

@ -1,43 +0,0 @@
package com.example.livestreaming;
import android.content.Context;
import android.content.SharedPreferences;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class PostManager {
private static final String PREFS_NAME = "posts_prefs";
private static final String KEY_POSTS = "posts";
private static final Gson gson = new Gson();
public static void savePost(Context context, Post post) {
List<Post> posts = getAllPosts(context);
posts.add(0, post);
savePosts(context, posts);
}
public static List<Post> getAllPosts(Context context) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String json = prefs.getString(KEY_POSTS, "[]");
Type type = new TypeToken<List<Post>>(){}.getType();
List<Post> posts = gson.fromJson(json, type);
return posts != null ? posts : new ArrayList<>();
}
public static List<Post> getPostsByCategory(Context context, String category) {
return getAllPosts(context).stream()
.filter(p -> category.equals(p.getCategory()))
.collect(Collectors.toList());
}
private static void savePosts(Context context, List<Post> posts) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
prefs.edit().putString(KEY_POSTS, gson.toJson(posts)).apply();
}
}

View File

@ -1,43 +0,0 @@
package com.example.livestreaming;
import android.app.Activity;
import android.app.AlertDialog;
import android.widget.EditText;
import android.widget.Toast;
public class PublishPostHelper {
public interface PublishCallback {
void onSuccess(Post post);
void onError(String error);
}
public static void showPublishDialog(Activity activity, String category, PublishCallback callback) {
EditText input = new EditText(activity);
input.setHint("输入内容...");
new AlertDialog.Builder(activity)
.setTitle("发布动态")
.setView(input)
.setPositiveButton("发布", (dialog, which) -> {
String content = input.getText().toString().trim();
if (content.isEmpty()) {
Toast.makeText(activity, "内容不能为空", Toast.LENGTH_SHORT).show();
return;
}
Post post = new Post();
post.setId(String.valueOf(System.currentTimeMillis()));
post.setContent(content);
post.setCategory(category);
post.setUserName("用户");
post.setTimestamp(System.currentTimeMillis());
PostManager.savePost(activity, post);
if (callback != null) {
callback.onSuccess(post);
}
})
.setNegativeButton("取消", null)
.show();
}
}

View File

@ -1,96 +0,0 @@
package com.example.livestreaming;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.example.livestreaming.net.Room;
public class RoomAdapter extends ListAdapter<Room, RoomAdapter.RoomViewHolder> {
private OnRoomClickListener listener;
public interface OnRoomClickListener {
void onRoomClick(Room room);
}
public RoomAdapter() {
super(new DiffUtil.ItemCallback<Room>() {
@Override
public boolean areItemsTheSame(@NonNull Room oldItem, @NonNull Room newItem) {
return oldItem.getId() != null && oldItem.getId().equals(newItem.getId());
}
@Override
public boolean areContentsTheSame(@NonNull Room oldItem, @NonNull Room newItem) {
return oldItem.equals(newItem);
}
});
}
public void setOnRoomClickListener(OnRoomClickListener listener) {
this.listener = listener;
}
@NonNull
@Override
public RoomViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_room, parent, false);
return new RoomViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull RoomViewHolder holder, int position) {
Room room = getItem(position);
holder.bind(room, listener);
}
static class RoomViewHolder extends RecyclerView.ViewHolder {
private final TextView tvTitle;
private final TextView tvStreamer;
private final TextView tvLikeCount;
private final ImageView ivCover;
private final View liveIndicator;
public RoomViewHolder(@NonNull View itemView) {
super(itemView);
tvTitle = itemView.findViewById(R.id.roomTitle);
tvStreamer = itemView.findViewById(R.id.streamerName);
tvLikeCount = itemView.findViewById(R.id.likeCount);
ivCover = itemView.findViewById(R.id.coverImage);
liveIndicator = itemView.findViewById(R.id.liveBadge);
}
public void bind(Room room, OnRoomClickListener listener) {
if (tvTitle != null) tvTitle.setText(room.getTitle());
if (tvStreamer != null) tvStreamer.setText(room.getStreamerName());
if (tvLikeCount != null) tvLikeCount.setText(String.valueOf(room.getViewerCount()));
if (liveIndicator != null) {
liveIndicator.setVisibility(room.isLive() ? View.VISIBLE : View.GONE);
}
if (ivCover != null && room.getCoverImage() != null) {
Glide.with(itemView.getContext())
.load(room.getCoverImage())
.placeholder(android.R.drawable.ic_menu_gallery)
.into(ivCover);
}
itemView.setOnClickListener(v -> {
if (listener != null) {
listener.onRoomClick(room);
}
});
}
}
}

View File

@ -100,28 +100,12 @@ public class RoomDetailActivity extends AppCompatActivity {
// WebSocket - 弹幕
private WebSocket chatWebSocket;
private OkHttpClient chatWsClient;
private static final String WS_CHAT_BASE_URL = "ws://192.168.1.164:8081/ws/live/chat/";
// WebSocket - 在线人数
private WebSocket onlineCountWebSocket;
private OkHttpClient onlineCountWsClient;
// 动态获取WebSocket URL
private String getWsChatBaseUrl() {
String baseUrl = ApiClient.getCurrentBaseUrl(this);
if (baseUrl == null || baseUrl.isEmpty()) {
baseUrl = "http://192.168.1.164:8081/";
}
// http:// 转换为 ws://
return baseUrl.replace("http://", "ws://").replace("https://", "wss://") + "ws/live/chat/";
}
private String getWsOnlineBaseUrl() {
String baseUrl = ApiClient.getCurrentBaseUrl(this);
if (baseUrl == null || baseUrl.isEmpty()) {
baseUrl = "http://192.168.1.164:8081/";
}
return baseUrl.replace("http://", "ws://").replace("https://", "wss://") + "ws/live/";
}
private static final String WS_ONLINE_BASE_URL = "ws://192.168.1.164:8081/ws/live/";
// WebSocket 心跳检测 - 弹幕
private Runnable chatHeartbeatRunnable;
@ -281,7 +265,7 @@ public class RoomDetailActivity extends AppCompatActivity {
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS) // OkHttp 内置 ping
.build();
Request request = new Request.Builder()
.url(getWsChatBaseUrl() + roomId)
.url(WS_CHAT_BASE_URL + roomId)
.build();
chatWebSocket = chatWsClient.newWebSocket(request, new WebSocketListener() {
@ -388,7 +372,7 @@ public class RoomDetailActivity extends AppCompatActivity {
String clientId = (userIdStr != null && !userIdStr.isEmpty()) ?
userIdStr :
"guest_" + System.currentTimeMillis();
String wsUrl = getWsOnlineBaseUrl() + roomId + "?clientId=" + clientId;
String wsUrl = WS_ONLINE_BASE_URL + roomId + "?clientId=" + clientId;
onlineCountWsClient = new OkHttpClient.Builder()
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS)
@ -1066,19 +1050,14 @@ public class RoomDetailActivity extends AppCompatActivity {
if (ijkSurface == null) return;
IjkMediaPlayer p = new IjkMediaPlayer();
// 优化缓冲设置减少卡顿
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 1); // 开启缓冲
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 0);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer");
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 100000); // 100ms分析时长
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 10240); // 10KB探测大小
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 5); // 允许丢帧
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 3000); // 3秒缓存
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "min_frames", 50); // 最小缓冲帧数
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 0); // 关闭无限缓冲
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect", 1); // 断线重连
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect_streamed", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect_delay_max", 5); // 最大重连延迟5秒
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 1024);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 300);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1);
p.setOnPreparedListener(mp -> {
binding.offlineLayout.setVisibility(View.GONE);
@ -1087,36 +1066,14 @@ public class RoomDetailActivity extends AppCompatActivity {
});
p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> {
android.util.Log.e("IjkPlayer", "播放错误: what=" + what + ", extra=" + extra);
if (ijkFallbackTried || TextUtils.isEmpty(ijkFallbackHlsUrl)) {
binding.offlineLayout.setVisibility(View.VISIBLE);
// 5秒后尝试重新连接
handler.postDelayed(() -> {
if (!isFinishing() && !isDestroyed() && room != null && room.isLive()) {
fetchRoom(); // 重新获取房间信息并播放
}
}, 5000);
return true;
}
ijkFallbackTried = true;
startHls(ijkFallbackHlsUrl, null);
return true;
});
// 添加缓冲监听
p.setOnInfoListener((mp, what, extra) -> {
if (what == IMediaPlayer.MEDIA_INFO_BUFFERING_START) {
android.util.Log.d("IjkPlayer", "开始缓冲...");
// 可以显示加载指示器
} else if (what == IMediaPlayer.MEDIA_INFO_BUFFERING_END) {
android.util.Log.d("IjkPlayer", "缓冲结束");
// 隐藏加载指示器
} else if (what == IMediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) {
android.util.Log.d("IjkPlayer", "视频开始渲染");
binding.offlineLayout.setVisibility(View.GONE);
}
return false;
});
ijkPlayer = p;
try {
@ -1124,7 +1081,6 @@ public class RoomDetailActivity extends AppCompatActivity {
p.setDataSource(url);
p.prepareAsync();
} catch (Exception e) {
android.util.Log.e("IjkPlayer", "播放器初始化失败: " + e.getMessage());
if (!TextUtils.isEmpty(ijkFallbackHlsUrl)) {
startHls(ijkFallbackHlsUrl, null);
} else {
@ -1276,7 +1232,14 @@ public class RoomDetailActivity extends AppCompatActivity {
*/
private void setDefaultGifts() {
availableGifts = new ArrayList<>();
// 不再使用模拟数据只从后端接口获取真实礼物数据
availableGifts.add(new Gift("1", "玫瑰", 10, R.drawable.ic_gift_rose, 1));
availableGifts.add(new Gift("2", "爱心", 20, R.drawable.ic_gift_heart, 1));
availableGifts.add(new Gift("3", "蛋糕", 50, R.drawable.ic_gift_cake, 2));
availableGifts.add(new Gift("4", "星星", 100, R.drawable.ic_gift_star, 2));
availableGifts.add(new Gift("5", "钻石", 200, R.drawable.ic_gift_diamond, 3));
availableGifts.add(new Gift("6", "皇冠", 500, R.drawable.ic_gift_crown, 4));
availableGifts.add(new Gift("7", "跑车", 1000, R.drawable.ic_gift_car, 5));
availableGifts.add(new Gift("8", "火箭", 2000, R.drawable.ic_gift_rocket, 5));
}
/**
@ -1543,7 +1506,7 @@ public class RoomDetailActivity extends AppCompatActivity {
*/
private void showPaymentMethodDialog(String orderId, RechargeOption selectedOption,
androidx.appcompat.app.AlertDialog rechargeDialog) {
String[] paymentMethods = {"支付宝支付", "微信支付"};
String[] paymentMethods = {"支付宝支付", "微信支付", "余额支付(模拟)"};
new MaterialAlertDialogBuilder(this)
.setTitle("选择支付方式")
@ -1560,6 +1523,10 @@ public class RoomDetailActivity extends AppCompatActivity {
payType = "weixin";
payChannel = "weixinAppAndroid";
break;
case 2: // 余额支付模拟
// 模拟充值成功
simulateRechargeSuccess(selectedOption, rechargeDialog);
return;
default:
return;
}
@ -1601,9 +1568,8 @@ public class RoomDetailActivity extends AppCompatActivity {
"\n请集成支付SDK完成实际支付",
Toast.LENGTH_LONG).show();
// TODO: 集成支付SDK后在支付成功回调中更新余额
// 支付成功后应该调用后端接口查询订单状态并更新余额
rechargeDialog.dismiss();
// 暂时模拟支付成功
simulateRechargeSuccess(selectedOption, rechargeDialog);
} else {
Toast.makeText(RoomDetailActivity.this,
"支付失败: " + apiResponse.getMessage(),
@ -1625,6 +1591,28 @@ public class RoomDetailActivity extends AppCompatActivity {
});
}
/**
* 模拟充值成功
*/
private void simulateRechargeSuccess(RechargeOption selectedOption,
androidx.appcompat.app.AlertDialog rechargeDialog) {
userCoinBalance += selectedOption.getCoinAmount();
// 更新礼物弹窗中的余额显示
if (giftDialog != null && giftDialog.isShowing()) {
View giftView = giftDialog.findViewById(R.id.coinBalance);
if (giftView instanceof android.widget.TextView) {
((android.widget.TextView) giftView).setText(String.valueOf(userCoinBalance));
}
}
Toast.makeText(this,
String.format("充值成功!获得 %d 金币", selectedOption.getCoinAmount()),
Toast.LENGTH_SHORT).show();
rechargeDialog.dismiss();
}
/**
* 从后端加载用户金币余额
*/

View File

@ -367,18 +367,47 @@ public class TabPlaceholderActivity extends AppCompatActivity {
}
private List<Room> buildFollowDemoRooms(int count) {
// 不再使用模拟数据只从后端接口获取真实关注主播的直播间数据
return new ArrayList<>();
List<Room> list = new ArrayList<>();
for (int i = 0; i < count; i++) {
String id = "follow-" + i;
String title = "关注主播直播间 " + (i + 1);
String streamer = "已关注主播" + (i + 1);
boolean live = i % 4 != 0;
list.add(new Room(id, title, streamer, live));
}
return list;
}
private List<NearbyUser> buildNearbyDemoUsers(int count) {
// 不再使用模拟数据只从后端接口获取真实附近用户数据
return new ArrayList<>();
List<NearbyUser> list = new ArrayList<>();
String[] names = {"小王", "小李", "安安", "小陈", "小美", "老张", "小七", "阿杰",
"小雨", "阿宁", "小星", "小林", "小杨", "小刘", "小赵", "小孙", "小周", "小吴"};
for (int i = 0; i < count; i++) {
String id = "user-" + i;
String name = i < names.length ? names[i] : "用户" + (i + 1);
boolean live = false; // 不再显示直播状态
String distanceText;
if (i < 3) {
distanceText = (300 + i * 120) + "m";
} else {
float km = 0.8f + (i - 3) * 0.35f;
distanceText = String.format("%.1fkm", km);
}
list.add(new NearbyUser(id, name, distanceText, live));
}
return list;
}
private List<Room> buildDiscoverDemoRooms(int count) {
// 不再使用模拟数据只从后端接口获取真实推荐直播间数据
return new ArrayList<>();
List<Room> list = new ArrayList<>();
for (int i = 0; i < count; i++) {
String id = "discover-" + i;
String title = "推荐直播间 " + (i + 1);
String streamer = "推荐主播" + (i + 1);
boolean live = i % 4 != 0;
list.add(new Room(id, title, streamer, live));
}
return list;
}
private void showParkBadges() {
@ -424,8 +453,17 @@ public class TabPlaceholderActivity extends AppCompatActivity {
}
private List<BadgeItem> buildDemoBadges() {
// 不再使用模拟数据只从后端接口获取真实勋章数据
return new ArrayList<>();
List<BadgeItem> list = new ArrayList<>();
list.add(new BadgeItem("b-1", "新人报道", "首次完善个人资料", R.drawable.ic_person_24, true, false));
list.add(new BadgeItem("b-2", "热度新星", "累计获得100次点赞", R.drawable.ic_heart_24, false, false));
list.add(new BadgeItem("b-3", "连续签到", "连续签到7天", R.drawable.ic_grid_24, false, true));
list.add(new BadgeItem("b-4", "分享达人", "分享主页3次", R.drawable.ic_copy_24, false, false));
list.add(new BadgeItem("b-5", "探索者", "进入发现页10次", R.drawable.ic_globe_24, true, false));
list.add(new BadgeItem("b-6", "公园守护", "完成公园任务5次", R.drawable.ic_tree_24, false, true));
list.add(new BadgeItem("b-7", "话题参与", "发布话题内容1次", R.drawable.ic_palette_24, false, false));
list.add(new BadgeItem("b-8", "社交达人", "添加好友5人", R.drawable.ic_people_24, false, true));
list.add(new BadgeItem("b-9", "开播尝鲜", "创建直播间1次", R.drawable.ic_mic_24, false, false));
return list;
}
private void showMore() {

View File

@ -55,7 +55,14 @@ public class WatchHistoryActivity extends AppCompatActivity {
}
private List<Room> buildDemoHistory(int count) {
// 不再使用模拟数据只从后端接口获取真实观看历史数据
return new ArrayList<>();
List<Room> list = new ArrayList<>();
for (int i = 0; i < count; i++) {
String id = "history-" + i;
String title = "看过的直播间 " + (i + 1);
String streamer = "主播" + (i + 1);
boolean live = i % 5 != 0;
list.add(new Room(id, title, streamer, live));
}
return list;
}
}

View File

@ -1,51 +1,27 @@
package com.example.livestreaming.call;
import android.Manifest;
import android.content.pm.PackageManager;
import android.media.AudioManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.bumptech.glide.Glide;
import com.example.livestreaming.R;
import org.json.JSONException;
import org.json.JSONObject;
import org.webrtc.IceCandidate;
import org.webrtc.MediaStream;
import org.webrtc.PeerConnection;
import org.webrtc.SessionDescription;
import org.webrtc.SurfaceViewRenderer;
import java.util.ArrayList;
import java.util.List;
/**
* 通话界面 - 集成 WebRTC
* 通话界面
*/
public class CallActivity extends AppCompatActivity implements
CallManager.CallStateListener,
WebRTCClient.WebRTCListener {
public class CallActivity extends AppCompatActivity implements CallManager.CallStateListener {
private static final String TAG = "CallActivity";
private static final int PERMISSION_REQUEST_CODE = 100;
// UI 组件
private ImageView ivBackgroundAvatar;
private ImageView ivAvatar;
private TextView tvUserName;
@ -60,20 +36,11 @@ public class CallActivity extends AppCompatActivity implements
private ImageButton btnSwitchCamera;
private LinearLayout layoutCallControls;
private LinearLayout layoutVideoToggle;
private FrameLayout layoutLocalVideo;
private FrameLayout layoutRemoteVideo;
// WebRTC 视频渲染器
private SurfaceViewRenderer localRenderer;
private SurfaceViewRenderer remoteRenderer;
// 管理器
private CallManager callManager;
private WebRTCClient webRTCClient;
private AudioManager audioManager;
private Handler handler;
// 通话信息
private String callId;
private String callType;
private boolean isCaller;
@ -81,18 +48,12 @@ public class CallActivity extends AppCompatActivity implements
private String otherUserName;
private String otherUserAvatar;
// 状态
private boolean isMuted = false;
private boolean isSpeakerOn = false;
private boolean isVideoEnabled = true;
private boolean isConnected = false;
private boolean isWebRTCInitialized = false;
private long callStartTime = 0;
// ICE Candidate 缓存在远程 SDP 设置前收到的
private List<IceCandidate> pendingIceCandidates = new ArrayList<>();
private boolean remoteDescriptionSet = false;
private Runnable durationRunnable = new Runnable() {
@Override
public void run() {
@ -109,8 +70,6 @@ public class CallActivity extends AppCompatActivity implements
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.d(TAG, "========== CallActivity onCreate ==========");
Toast.makeText(this, "通话界面已打开", Toast.LENGTH_SHORT).show();
// 保持屏幕常亮显示在锁屏上方
getWindow().addFlags(
@ -123,20 +82,9 @@ public class CallActivity extends AppCompatActivity implements
initViews();
initData();
Log.d(TAG, "callId=" + callId + ", callType=" + callType + ", isCaller=" + isCaller);
Toast.makeText(this, "通话类型: " + callType + ", 主叫: " + isCaller, Toast.LENGTH_SHORT).show();
// 检查权限
if (checkPermissions()) {
Log.d(TAG, "权限已授予,初始化通话");
Toast.makeText(this, "权限OK初始化WebRTC", Toast.LENGTH_SHORT).show();
initCallAndWebRTC();
} else {
Log.d(TAG, "请求权限");
Toast.makeText(this, "请求权限中...", Toast.LENGTH_SHORT).show();
requestPermissions();
}
initCallManager();
setupListeners();
updateUI();
}
private void initViews() {
@ -154,62 +102,9 @@ public class CallActivity extends AppCompatActivity implements
btnSwitchCamera = findViewById(R.id.btnSwitchCamera);
layoutCallControls = findViewById(R.id.layoutCallControls);
layoutVideoToggle = findViewById(R.id.layoutVideoToggle);
layoutLocalVideo = findViewById(R.id.layoutLocalVideo);
layoutRemoteVideo = findViewById(R.id.layoutRemoteVideo);
handler = new Handler(Looper.getMainLooper());
audioManager = (AudioManager) getSystemService(AUDIO_SERVICE);
// 设置音频模式为通话模式重要WebRTC音频需要此设置
setupAudioForCall();
setupListeners();
}
/**
* 设置音频为通话模式
*/
private void setupAudioForCall() {
Log.d(TAG, "========== 设置音频模式 ==========");
try {
// 保存原始音频模式
int originalMode = audioManager.getMode();
Log.d(TAG, "原始音频模式: " + originalMode);
// 设置为通话模式
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
Log.d(TAG, "设置音频模式为 MODE_IN_COMMUNICATION");
// 请求音频焦点
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
android.media.AudioAttributes playbackAttributes = new android.media.AudioAttributes.Builder()
.setUsage(android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
.build();
android.media.AudioFocusRequest focusRequest = new android.media.AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(playbackAttributes)
.build();
audioManager.requestAudioFocus(focusRequest);
} else {
audioManager.requestAudioFocus(null, AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
}
Log.d(TAG, "已请求音频焦点");
// 默认使用听筒语音通话或扬声器视频通话
if ("video".equals(callType)) {
audioManager.setSpeakerphoneOn(true);
isSpeakerOn = true;
Log.d(TAG, "视频通话,默认开启扬声器");
} else {
audioManager.setSpeakerphoneOn(false);
isSpeakerOn = false;
Log.d(TAG, "语音通话,默认使用听筒");
}
Log.d(TAG, "音频设置完成");
} catch (Exception e) {
Log.e(TAG, "设置音频模式失败", e);
}
}
private void initData() {
@ -223,175 +118,36 @@ public class CallActivity extends AppCompatActivity implements
// 如果是被叫方接听直接进入通话状态
boolean alreadyConnected = getIntent().getBooleanExtra("isConnected", false);
if (alreadyConnected) {
Log.d(TAG, "被叫方接听,直接进入通话状态");
android.util.Log.d("CallActivity", "被叫方接听,直接进入通话状态");
isConnected = true;
callStartTime = System.currentTimeMillis();
}
updateUI();
}
private boolean checkPermissions() {
boolean audioPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED;
boolean cameraPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED;
if ("video".equals(callType)) {
return audioPermission && cameraPermission;
}
return audioPermission;
}
private void requestPermissions() {
List<String> permissions = new ArrayList<>();
permissions.add(Manifest.permission.RECORD_AUDIO);
if ("video".equals(callType)) {
permissions.add(Manifest.permission.CAMERA);
}
ActivityCompat.requestPermissions(this, permissions.toArray(new String[0]), PERMISSION_REQUEST_CODE);
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PERMISSION_REQUEST_CODE) {
boolean allGranted = true;
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
allGranted = false;
break;
}
}
if (allGranted) {
initCallAndWebRTC();
} else {
Toast.makeText(this, "需要麦克风和摄像头权限才能进行通话", Toast.LENGTH_LONG).show();
finish();
}
}
}
private void initCallAndWebRTC() {
initCallManager();
initWebRTC();
}
private void initCallManager() {
callManager = CallManager.getInstance(this);
callManager.setStateListener(this);
// 确保WebSocket已连接
// 确保WebSocket已连接主叫方需要接收接听/拒绝通知
String userId = com.example.livestreaming.net.AuthStore.getUserId(this);
if (userId != null && !userId.isEmpty()) {
try {
int uid = (int) Double.parseDouble(userId);
if (uid > 0) {
Log.d(TAG, "确保WebSocket连接userId: " + uid);
android.util.Log.d("CallActivity", "确保WebSocket连接userId: " + uid);
callManager.connect(uid);
}
} catch (NumberFormatException e) {
Log.e(TAG, "解析用户ID失败", e);
android.util.Log.e("CallActivity", "解析用户ID失败", e);
}
}
}
private void initWebRTC() {
Log.d(TAG, "========== 初始化 WebRTC ==========");
Toast.makeText(this, "开始初始化WebRTC...", Toast.LENGTH_SHORT).show();
boolean isVideoCall = "video".equals(callType);
Log.d(TAG, "isVideoCall=" + isVideoCall + ", callType=" + callType);
try {
// 创建 WebRTC 客户端
Toast.makeText(this, "创建WebRTC客户端...", Toast.LENGTH_SHORT).show();
webRTCClient = new WebRTCClient(this);
webRTCClient.setListener(this);
Toast.makeText(this, "调用initialize...", Toast.LENGTH_SHORT).show();
webRTCClient.initialize(isVideoCall);
Log.d(TAG, "WebRTC 客户端创建成功");
Toast.makeText(this, "initialize完成!", Toast.LENGTH_SHORT).show();
// 如果是视频通话设置视频渲染器
if (isVideoCall) {
try {
Toast.makeText(this, "设置视频渲染器...", Toast.LENGTH_SHORT).show();
setupVideoRenderers();
Toast.makeText(this, "视频渲染器设置成功", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
Log.e(TAG, "视频渲染器设置失败", e);
Toast.makeText(this, "视频渲染器失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
// 创建本地媒体流
Log.d(TAG, "创建本地媒体流...");
try {
Toast.makeText(this, "创建本地媒体流...", Toast.LENGTH_SHORT).show();
webRTCClient.createLocalStream();
Toast.makeText(this, "本地媒体流创建成功", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
Log.e(TAG, "本地媒体流创建失败", e);
Toast.makeText(this, "媒体流失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
// 创建 PeerConnection
Log.d(TAG, "创建 PeerConnection...");
try {
webRTCClient.createPeerConnection();
Toast.makeText(this, "PeerConnection创建成功", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
Log.e(TAG, "PeerConnection创建失败", e);
Toast.makeText(this, "PeerConnection失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
return;
}
isWebRTCInitialized = true;
Log.d(TAG, "WebRTC 初始化完成");
Toast.makeText(this, "WebRTC初始化完成!", Toast.LENGTH_SHORT).show();
// 主叫方等待对方接听后再创建 Offer onCallConnected 中创建
// 被叫方等待收到 Offer 后创建 Answer
if (isCaller) {
Log.d(TAG, "主叫方,等待对方接听后创建 Offer");
Toast.makeText(this, "等待对方接听...", Toast.LENGTH_SHORT).show();
} else {
Log.d(TAG, "被叫方,等待 Offer");
Toast.makeText(this, "被叫方等待Offer...", Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "WebRTC 初始化失败", e);
Toast.makeText(this, "WebRTC初始化失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
}
}
private void setupVideoRenderers() {
Log.d(TAG, "设置视频渲染器");
// 创建本地视频渲染器
localRenderer = new SurfaceViewRenderer(this);
layoutLocalVideo.addView(localRenderer);
webRTCClient.setLocalRenderer(localRenderer);
// 创建远程视频渲染器
remoteRenderer = new SurfaceViewRenderer(this);
layoutRemoteVideo.addView(remoteRenderer);
webRTCClient.setRemoteRenderer(remoteRenderer);
// 显示视频布局
layoutLocalVideo.setVisibility(View.VISIBLE);
layoutRemoteVideo.setVisibility(View.VISIBLE);
// 隐藏头像视频通话时
ivBackgroundAvatar.setVisibility(View.GONE);
ivAvatar.setVisibility(View.GONE);
}
private void setupListeners() {
btnMinimize.setOnClickListener(v -> moveTaskToBack(true));
btnMinimize.setOnClickListener(v -> {
// 最小化通话后台运行
moveTaskToBack(true);
});
btnMute.setOnClickListener(v -> toggleMute());
btnSpeaker.setOnClickListener(v -> toggleSpeaker());
@ -406,7 +162,7 @@ public class CallActivity extends AppCompatActivity implements
} else {
callManager.rejectCall(callId);
}
releaseAndFinish();
finish();
});
}
@ -427,11 +183,14 @@ public class CallActivity extends AppCompatActivity implements
// 根据连接状态设置界面
if (isConnected) {
// 已接通显示通话中界面
tvCallStatus.setVisibility(View.GONE);
tvCallDuration.setVisibility(View.VISIBLE);
layoutCallControls.setVisibility(View.VISIBLE);
handler.post(durationRunnable);
android.util.Log.d("CallActivity", "updateUI: 已接通状态,显示计时器");
} else {
// 未接通显示等待状态
if (isCaller) {
tvCallStatus.setText("正在呼叫...");
} else {
@ -444,10 +203,7 @@ public class CallActivity extends AppCompatActivity implements
isMuted = !isMuted;
btnMute.setImageResource(isMuted ? R.drawable.ic_mic_off : R.drawable.ic_mic);
btnMute.setBackgroundResource(isMuted ? R.drawable.bg_call_button_active : R.drawable.bg_call_button);
if (webRTCClient != null) {
webRTCClient.setMuted(isMuted);
}
// TODO: 实际静音控制
Toast.makeText(this, isMuted ? "已静音" : "已取消静音", Toast.LENGTH_SHORT).show();
}
@ -461,35 +217,17 @@ public class CallActivity extends AppCompatActivity implements
private void toggleVideo() {
isVideoEnabled = !isVideoEnabled;
btnVideo.setBackgroundResource(isVideoEnabled ? R.drawable.bg_call_button : R.drawable.bg_call_button_active);
if (webRTCClient != null) {
webRTCClient.setVideoEnabled(isVideoEnabled);
}
if (localRenderer != null) {
localRenderer.setVisibility(isVideoEnabled ? View.VISIBLE : View.GONE);
}
// TODO: 实际视频控制
Toast.makeText(this, isVideoEnabled ? "已开启摄像头" : "已关闭摄像头", Toast.LENGTH_SHORT).show();
}
private void switchCamera() {
if (webRTCClient != null) {
webRTCClient.switchCamera();
}
// TODO: 切换前后摄像头
Toast.makeText(this, "切换摄像头", Toast.LENGTH_SHORT).show();
}
private void onCallConnected() {
Log.d(TAG, "========== 通话已连接 ==========");
Log.d(TAG, "isCaller=" + isCaller + ", isWebRTCInitialized=" + isWebRTCInitialized);
// 如果是主叫方收到 call_accepted现在创建 Offer
if (isCaller && isWebRTCInitialized && webRTCClient != null) {
Log.d(TAG, "主叫方收到接听通知,开始创建 Offer");
Toast.makeText(this, "对方已接听,建立连接...", Toast.LENGTH_SHORT).show();
webRTCClient.createOffer();
}
android.util.Log.d("CallActivity", "onCallConnected() 开始执行");
isConnected = true;
callStartTime = System.currentTimeMillis();
@ -498,22 +236,14 @@ public class CallActivity extends AppCompatActivity implements
layoutCallControls.setVisibility(View.VISIBLE);
handler.post(durationRunnable);
android.util.Log.d("CallActivity", "onCallConnected() 执行完成,通话已连接");
Toast.makeText(this, "通话已接通", Toast.LENGTH_SHORT).show();
}
private void releaseAndFinish() {
if (webRTCClient != null) {
webRTCClient.release();
webRTCClient = null;
}
finish();
}
// ==================== CallStateListener 实现 ====================
// CallStateListener 实现
@Override
public void onCallStateChanged(String state, String callId) {
Log.d(TAG, "onCallStateChanged: " + state);
// 状态变化处理
}
@Override
@ -523,8 +253,13 @@ public class CallActivity extends AppCompatActivity implements
@Override
public void onCallConnected(String callId) {
Log.d(TAG, "onCallConnected: " + callId);
runOnUiThread(this::onCallConnected);
android.util.Log.d("CallActivity", "========== onCallConnected 被调用 ==========");
android.util.Log.d("CallActivity", "callId: " + callId);
android.util.Log.d("CallActivity", "this.callId: " + this.callId);
runOnUiThread(() -> {
android.util.Log.d("CallActivity", "执行 onCallConnected UI更新");
onCallConnected();
});
}
@Override
@ -551,7 +286,7 @@ public class CallActivity extends AppCompatActivity implements
message = "通话已结束";
}
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
releaseAndFinish();
finish();
});
}
@ -562,163 +297,18 @@ public class CallActivity extends AppCompatActivity implements
});
}
// ==================== WebRTCListener 实现 ====================
@Override
public void onLocalStream(MediaStream stream) {
Log.d(TAG, "本地媒体流已创建");
}
@Override
public void onRemoteStream(MediaStream stream) {
Log.d(TAG, "收到远程媒体流");
runOnUiThread(() -> {
if (!isConnected) {
onCallConnected();
}
});
}
@Override
public void onIceCandidate(IceCandidate candidate) {
Log.d(TAG, "========== 发送 ICE Candidate ==========");
// 通过信令服务器发送 ICE Candidate
if (callManager != null && callId != null) {
try {
JSONObject candidateJson = new JSONObject();
candidateJson.put("sdpMid", candidate.sdpMid);
candidateJson.put("sdpMLineIndex", candidate.sdpMLineIndex);
candidateJson.put("candidate", candidate.sdp);
callManager.sendIceCandidate(callId, candidateJson);
Log.d(TAG, "ICE Candidate 已发送");
} catch (JSONException e) {
Log.e(TAG, "构建 ICE Candidate JSON 失败", e);
}
} else {
Log.e(TAG, "无法发送ICE: callManager=" + callManager + ", callId=" + callId);
}
}
@Override
public void onIceConnectionChange(PeerConnection.IceConnectionState state) {
Log.d(TAG, "ICE 连接状态变化: " + state);
runOnUiThread(() -> {
switch (state) {
case CONNECTED:
case COMPLETED:
if (!isConnected) {
onCallConnected();
}
break;
case DISCONNECTED:
case FAILED:
Toast.makeText(this, "连接断开", Toast.LENGTH_SHORT).show();
releaseAndFinish();
break;
}
});
}
@Override
public void onOfferCreated(SessionDescription sdp) {
Log.d(TAG, "========== Offer 已创建 ==========");
Log.d(TAG, "callId=" + callId);
Log.d(TAG, "callManager=" + (callManager != null ? "存在" : "null"));
if (callManager != null && callId != null) {
callManager.sendOffer(callId, sdp.description);
Log.d(TAG, "Offer 已发送");
runOnUiThread(() -> Toast.makeText(this, "Offer已发送!", Toast.LENGTH_SHORT).show());
} else {
Log.e(TAG, "无法发送Offer: callManager=" + callManager + ", callId=" + callId);
runOnUiThread(() -> Toast.makeText(this, "发送Offer失败!", Toast.LENGTH_SHORT).show());
}
}
@Override
public void onAnswerCreated(SessionDescription sdp) {
Log.d(TAG, "Answer 已创建,发送给对方");
if (callManager != null && callId != null) {
callManager.sendAnswer(callId, sdp.description);
Log.d(TAG, "Answer 已发送");
}
}
// 处理收到的 Offer
public void handleRemoteOffer(String sdp) {
Log.d(TAG, "收到远程 Offer");
if (webRTCClient != null) {
SessionDescription offer = new SessionDescription(SessionDescription.Type.OFFER, sdp);
webRTCClient.setRemoteDescription(offer);
remoteDescriptionSet = true;
// 处理缓存的 ICE Candidates
for (IceCandidate candidate : pendingIceCandidates) {
webRTCClient.addIceCandidate(candidate);
}
pendingIceCandidates.clear();
}
}
// 处理收到的 Answer
public void handleRemoteAnswer(String sdp) {
Log.d(TAG, "收到远程 Answer");
if (webRTCClient != null) {
SessionDescription answer = new SessionDescription(SessionDescription.Type.ANSWER, sdp);
webRTCClient.setRemoteDescription(answer);
remoteDescriptionSet = true;
// 处理缓存的 ICE Candidates
for (IceCandidate candidate : pendingIceCandidates) {
webRTCClient.addIceCandidate(candidate);
}
pendingIceCandidates.clear();
}
}
// 处理收到的 ICE Candidate
public void handleRemoteIceCandidate(JSONObject candidateJson) {
try {
String sdpMid = candidateJson.getString("sdpMid");
int sdpMLineIndex = candidateJson.getInt("sdpMLineIndex");
String sdp = candidateJson.getString("candidate");
IceCandidate candidate = new IceCandidate(sdpMid, sdpMLineIndex, sdp);
if (remoteDescriptionSet && webRTCClient != null) {
webRTCClient.addIceCandidate(candidate);
} else {
// 缓存 ICE Candidate等远程 SDP 设置后再添加
pendingIceCandidates.add(candidate);
}
} catch (JSONException e) {
Log.e(TAG, "解析 ICE Candidate 失败", e);
}
}
@Override
protected void onDestroy() {
super.onDestroy();
handler.removeCallbacks(durationRunnable);
// 恢复音频模式
if (audioManager != null) {
audioManager.setMode(AudioManager.MODE_NORMAL);
audioManager.setSpeakerphoneOn(false);
Log.d(TAG, "音频模式已恢复");
}
if (callManager != null) {
callManager.setStateListener(null);
}
if (webRTCClient != null) {
webRTCClient.release();
webRTCClient = null;
}
}
@Override
public void onBackPressed() {
// 禁止返回键退出需要点击挂断
moveTaskToBack(true);
}
}

View File

@ -351,86 +351,18 @@ public class CallManager implements CallSignalingClient.SignalingListener {
// 启动来电界面
Log.d(TAG, "启动来电界面 IncomingCallActivity");
Intent intent = new Intent(context, IncomingCallActivity.class);
// 添加多个 flags 确保能从后台启动
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_SINGLE_TOP
| Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra("callId", callId);
intent.putExtra("callType", callType);
intent.putExtra("callerId", callerId);
intent.putExtra("callerName", callerName);
intent.putExtra("callerAvatar", callerAvatar);
try {
context.startActivity(intent);
Log.d(TAG, "来电界面启动成功");
} catch (Exception e) {
Log.e(TAG, "启动来电界面失败: " + e.getMessage(), e);
// 如果直接启动失败尝试使用通知方式
showIncomingCallNotification(callId, callerId, callerName, callerAvatar, callType);
}
context.startActivity(intent);
if (stateListener != null) {
stateListener.onIncomingCall(callId, callerId, callerName, callerAvatar, callType);
}
}
/**
* 显示来电通知当无法直接启动Activity时使用
*/
private void showIncomingCallNotification(String callId, int callerId, String callerName, String callerAvatar, String callType) {
try {
android.app.NotificationManager notificationManager =
(android.app.NotificationManager) context.getSystemService(android.content.Context.NOTIFICATION_SERVICE);
// 创建通知渠道 (Android 8.0+)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
android.app.NotificationChannel channel = new android.app.NotificationChannel(
"incoming_call",
"来电通知",
android.app.NotificationManager.IMPORTANCE_HIGH
);
channel.setDescription("显示来电通知");
channel.enableVibration(true);
channel.setLockscreenVisibility(android.app.Notification.VISIBILITY_PUBLIC);
notificationManager.createNotificationChannel(channel);
}
// 创建点击通知时启动的Intent
Intent intent = new Intent(context, IncomingCallActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.putExtra("callId", callId);
intent.putExtra("callType", callType);
intent.putExtra("callerId", callerId);
intent.putExtra("callerName", callerName);
intent.putExtra("callerAvatar", callerAvatar);
android.app.PendingIntent pendingIntent = android.app.PendingIntent.getActivity(
context, 0, intent,
android.app.PendingIntent.FLAG_UPDATE_CURRENT | android.app.PendingIntent.FLAG_IMMUTABLE
);
// 构建通知
String callTypeText = "video".equals(callType) ? "视频通话" : "语音通话";
androidx.core.app.NotificationCompat.Builder builder =
new androidx.core.app.NotificationCompat.Builder(context, "incoming_call")
.setSmallIcon(android.R.drawable.ic_menu_call)
.setContentTitle(callerName + "" + callTypeText)
.setContentText("点击接听")
.setPriority(androidx.core.app.NotificationCompat.PRIORITY_HIGH)
.setCategory(androidx.core.app.NotificationCompat.CATEGORY_CALL)
.setFullScreenIntent(pendingIntent, true)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setOngoing(true);
notificationManager.notify(1001, builder.build());
Log.d(TAG, "来电通知已显示");
} catch (Exception e) {
Log.e(TAG, "显示来电通知失败: " + e.getMessage(), e);
}
}
@Override
public void onCallAccepted(String callId) {
@ -485,56 +417,17 @@ public class CallManager implements CallSignalingClient.SignalingListener {
@Override
public void onOffer(String callId, String sdp) {
Log.d(TAG, "收到 Offer");
// 通知 CallActivity 处理 Offer
if (stateListener instanceof CallActivity) {
((CallActivity) stateListener).handleRemoteOffer(sdp);
}
// WebRTC offer处理 - 后续实现
}
@Override
public void onAnswer(String callId, String sdp) {
Log.d(TAG, "收到 Answer");
// 通知 CallActivity 处理 Answer
if (stateListener instanceof CallActivity) {
((CallActivity) stateListener).handleRemoteAnswer(sdp);
}
// WebRTC answer处理 - 后续实现
}
@Override
public void onIceCandidate(String callId, JSONObject candidate) {
Log.d(TAG, "收到 ICE Candidate");
// 通知 CallActivity 处理 ICE Candidate
if (stateListener instanceof CallActivity) {
((CallActivity) stateListener).handleRemoteIceCandidate(candidate);
}
}
/**
* 发送 Offer
*/
public void sendOffer(String callId, String sdp) {
if (signalingClient != null) {
signalingClient.sendOffer(callId, sdp);
}
}
/**
* 发送 Answer
*/
public void sendAnswer(String callId, String sdp) {
if (signalingClient != null) {
signalingClient.sendAnswer(callId, sdp);
}
}
/**
* 发送 ICE Candidate
*/
public void sendIceCandidate(String callId, JSONObject candidate) {
if (signalingClient != null) {
signalingClient.sendIceCandidate(callId, candidate);
}
// WebRTC ICE candidate处理 - 后续实现
}
public interface CallCallback {

View File

@ -26,12 +26,6 @@ public class CallSignalingClient {
private String baseUrl;
private int userId;
private boolean isConnected = false;
private boolean isManualDisconnect = false;
private int reconnectAttempts = 0;
private static final int MAX_RECONNECT_ATTEMPTS = 5;
private static final long RECONNECT_DELAY_MS = 3000;
private static final long HEARTBEAT_INTERVAL_MS = 30000; // 30秒心跳
private Runnable heartbeatRunnable;
public interface SignalingListener {
void onConnected();
@ -60,26 +54,19 @@ public class CallSignalingClient {
}
public void connect() {
isManualDisconnect = false;
doConnect();
}
private void doConnect() {
String wsUrl = baseUrl.replace("http://", "ws://").replace("https://", "wss://");
if (!wsUrl.endsWith("/")) wsUrl += "/";
wsUrl += "ws/call";
Log.d(TAG, "Connecting to: " + wsUrl + " (attempt " + (reconnectAttempts + 1) + ")");
Log.d(TAG, "Connecting to: " + wsUrl);
Request request = new Request.Builder().url(wsUrl).build();
webSocket = client.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket webSocket, Response response) {
Log.d(TAG, "WebSocket connected successfully!");
Log.d(TAG, "WebSocket connected");
isConnected = true;
reconnectAttempts = 0; // 重置重连计数
register();
startHeartbeat();
notifyConnected();
}
@ -99,83 +86,24 @@ public class CallSignalingClient {
Log.d(TAG, "WebSocket closed: " + reason);
isConnected = false;
notifyDisconnected();
// 非手动断开时尝试重连
if (!isManualDisconnect) {
scheduleReconnect();
}
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
Log.e(TAG, "WebSocket connection failed: " + t.getMessage(), t);
Log.e(TAG, "WebSocket error", t);
isConnected = false;
notifyError("连接失败: " + t.getMessage());
// 尝试重连
if (!isManualDisconnect) {
scheduleReconnect();
}
notifyError(t.getMessage());
}
});
}
private void scheduleReconnect() {
if (isManualDisconnect) {
Log.d(TAG, "手动断开,不重连");
return;
}
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Log.e(TAG, "达到最大重连次数(" + MAX_RECONNECT_ATTEMPTS + "),停止重连");
notifyError("WebSocket连接失败已达最大重试次数");
return;
}
reconnectAttempts++;
long delay = RECONNECT_DELAY_MS * reconnectAttempts;
Log.d(TAG, "将在 " + delay + "ms 后重连 (第" + reconnectAttempts + "次)");
mainHandler.postDelayed(() -> {
if (!isConnected && !isManualDisconnect) {
doConnect();
}
}, delay);
}
public void disconnect() {
isManualDisconnect = true;
reconnectAttempts = 0;
stopHeartbeat();
if (webSocket != null) {
webSocket.close(1000, "User disconnect");
webSocket = null;
}
isConnected = false;
}
private void startHeartbeat() {
stopHeartbeat();
heartbeatRunnable = new Runnable() {
@Override
public void run() {
if (isConnected && webSocket != null) {
try {
JSONObject ping = new JSONObject();
ping.put("type", "ping");
webSocket.send(ping.toString());
Log.d(TAG, "发送心跳 ping");
} catch (JSONException e) {
Log.e(TAG, "心跳发送失败", e);
}
mainHandler.postDelayed(this, HEARTBEAT_INTERVAL_MS);
}
}
};
mainHandler.postDelayed(heartbeatRunnable, HEARTBEAT_INTERVAL_MS);
}
private void stopHeartbeat() {
if (heartbeatRunnable != null) {
mainHandler.removeCallbacks(heartbeatRunnable);
heartbeatRunnable = null;
}
}
public boolean isConnected() {
return isConnected;

View File

@ -1,729 +0,0 @@
package com.example.livestreaming.call;
import android.content.Context;
import android.util.Log;
import org.webrtc.AudioSource;
import org.webrtc.AudioTrack;
import org.webrtc.Camera1Enumerator;
import org.webrtc.Camera2Enumerator;
import org.webrtc.CameraEnumerator;
import org.webrtc.DataChannel;
import org.webrtc.DefaultVideoDecoderFactory;
import org.webrtc.DefaultVideoEncoderFactory;
import org.webrtc.EglBase;
import org.webrtc.IceCandidate;
import org.webrtc.MediaConstraints;
import org.webrtc.MediaStream;
import org.webrtc.PeerConnection;
import org.webrtc.PeerConnectionFactory;
import org.webrtc.RtpReceiver;
import org.webrtc.SdpObserver;
import org.webrtc.SessionDescription;
import org.webrtc.SurfaceTextureHelper;
import org.webrtc.SurfaceViewRenderer;
import org.webrtc.VideoCapturer;
import org.webrtc.VideoSource;
import org.webrtc.VideoTrack;
import java.util.ArrayList;
import java.util.List;
/**
* WebRTC 客户端 - 处理音视频传输
*/
public class WebRTCClient {
private static final String TAG = "WebRTCClient";
private Context context;
private EglBase eglBase;
private PeerConnectionFactory peerConnectionFactory;
private PeerConnection peerConnection;
private AudioSource audioSource;
private AudioTrack localAudioTrack;
private VideoSource videoSource;
private VideoTrack localVideoTrack;
private VideoCapturer videoCapturer;
private SurfaceTextureHelper surfaceTextureHelper;
private SurfaceViewRenderer localRenderer;
private SurfaceViewRenderer remoteRenderer;
private WebRTCListener listener;
private boolean isVideoCall = false;
private boolean isMuted = false;
private boolean isVideoEnabled = true;
private boolean useFrontCamera = true;
// ICE 服务器配置 - 可通过 WebRTCConfig 修改
private static List<PeerConnection.IceServer> getIceServers() {
List<PeerConnection.IceServer> iceServers = new ArrayList<>();
// STUN 服务器用于获取公网 IP
for (String stunServer : WebRTCConfig.STUN_SERVERS) {
iceServers.add(PeerConnection.IceServer.builder(stunServer).createIceServer());
}
// TURN 服务器用于 NAT 穿透失败时的中继
if (WebRTCConfig.TURN_SERVER_URL != null && !WebRTCConfig.TURN_SERVER_URL.isEmpty()) {
// UDP
iceServers.add(PeerConnection.IceServer.builder(WebRTCConfig.TURN_SERVER_URL)
.setUsername(WebRTCConfig.TURN_USERNAME)
.setPassword(WebRTCConfig.TURN_PASSWORD)
.createIceServer());
// TCP备用
iceServers.add(PeerConnection.IceServer.builder(WebRTCConfig.TURN_SERVER_URL + "?transport=tcp")
.setUsername(WebRTCConfig.TURN_USERNAME)
.setPassword(WebRTCConfig.TURN_PASSWORD)
.createIceServer());
}
return iceServers;
}
public interface WebRTCListener {
void onLocalStream(MediaStream stream);
void onRemoteStream(MediaStream stream);
void onIceCandidate(IceCandidate candidate);
void onIceConnectionChange(PeerConnection.IceConnectionState state);
void onOfferCreated(SessionDescription sdp);
void onAnswerCreated(SessionDescription sdp);
void onError(String error);
}
public WebRTCClient(Context context) {
this.context = context.getApplicationContext();
}
public void setListener(WebRTCListener listener) {
this.listener = listener;
}
/**
* 初始化 WebRTC
*/
public void initialize(boolean isVideoCall) {
this.isVideoCall = isVideoCall;
Log.d(TAG, "初始化 WebRTC, isVideoCall=" + isVideoCall);
// 在后台线程初始化避免阻塞UI
new Thread(() -> {
try {
// 初始化 EglBase
Log.d(TAG, "创建 EglBase...");
eglBase = EglBase.create();
Log.d(TAG, "EglBase 创建成功");
// 初始化 PeerConnectionFactory
Log.d(TAG, "初始化 PeerConnectionFactory...");
PeerConnectionFactory.InitializationOptions initOptions = PeerConnectionFactory.InitializationOptions.builder(context)
.setEnableInternalTracer(false)
.createInitializationOptions();
PeerConnectionFactory.initialize(initOptions);
Log.d(TAG, "PeerConnectionFactory 初始化成功");
PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
Log.d(TAG, "创建视频编解码器...");
DefaultVideoEncoderFactory encoderFactory = new DefaultVideoEncoderFactory(
eglBase.getEglBaseContext(), true, true);
DefaultVideoDecoderFactory decoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext());
Log.d(TAG, "视频编解码器创建成功");
Log.d(TAG, "创建 PeerConnectionFactory...");
peerConnectionFactory = PeerConnectionFactory.builder()
.setOptions(options)
.setVideoEncoderFactory(encoderFactory)
.setVideoDecoderFactory(decoderFactory)
.createPeerConnectionFactory();
Log.d(TAG, "PeerConnectionFactory 创建成功");
} catch (Exception e) {
Log.e(TAG, "WebRTC 初始化异常", e);
if (listener != null) {
listener.onError("WebRTC初始化失败: " + e.getMessage());
}
}
}).start();
// 等待初始化完成最多5秒
int waitCount = 0;
while (peerConnectionFactory == null && waitCount < 50) {
try {
Thread.sleep(100);
waitCount++;
} catch (InterruptedException e) {
break;
}
}
if (peerConnectionFactory == null) {
Log.e(TAG, "WebRTC 初始化超时");
if (listener != null) {
listener.onError("WebRTC初始化超时");
}
} else {
Log.d(TAG, "WebRTC 初始化完成,耗时: " + (waitCount * 100) + "ms");
}
}
/**
* 设置本地视频渲染器
*/
public void setLocalRenderer(SurfaceViewRenderer renderer) {
this.localRenderer = renderer;
if (localRenderer != null) {
localRenderer.init(eglBase.getEglBaseContext(), null);
localRenderer.setMirror(true);
localRenderer.setEnableHardwareScaler(true);
}
}
/**
* 设置远程视频渲染器
*/
public void setRemoteRenderer(SurfaceViewRenderer renderer) {
this.remoteRenderer = renderer;
if (remoteRenderer != null) {
remoteRenderer.init(eglBase.getEglBaseContext(), null);
remoteRenderer.setMirror(false);
remoteRenderer.setEnableHardwareScaler(true);
}
}
/**
* 创建本地媒体流
*/
public void createLocalStream() {
Log.d(TAG, "========== 创建本地媒体流 ==========");
Log.d(TAG, "isVideoCall=" + isVideoCall);
try {
// 创建音频轨道
MediaConstraints audioConstraints = new MediaConstraints();
audioConstraints.mandatory.add(new MediaConstraints.KeyValuePair("googEchoCancellation", "true"));
audioConstraints.mandatory.add(new MediaConstraints.KeyValuePair("googAutoGainControl", "true"));
audioConstraints.mandatory.add(new MediaConstraints.KeyValuePair("googHighpassFilter", "true"));
audioConstraints.mandatory.add(new MediaConstraints.KeyValuePair("googNoiseSuppression", "true"));
if (peerConnectionFactory != null) {
audioSource = peerConnectionFactory.createAudioSource(audioConstraints);
if (audioSource != null) {
localAudioTrack = peerConnectionFactory.createAudioTrack("ARDAMSa0", audioSource);
if (localAudioTrack != null) {
localAudioTrack.setEnabled(true);
Log.d(TAG, "音频轨道创建成功");
} else {
Log.w(TAG, "音频轨道创建返回null继续执行");
}
} else {
Log.w(TAG, "音频源创建返回null继续执行");
}
} else {
Log.e(TAG, "peerConnectionFactory为null!");
if (listener != null) {
listener.onError("PeerConnectionFactory未初始化");
}
return;
}
} catch (Exception e) {
Log.e(TAG, "音频轨道创建失败,继续执行", e);
// 不返回继续尝试创建视频轨道
}
// 如果是视频通话创建视频轨道
if (isVideoCall) {
Log.d(TAG, "视频通话,开始创建视频轨道");
try {
createVideoTrack();
Log.d(TAG, "视频轨道创建结果: localVideoTrack=" + (localVideoTrack != null));
} catch (Exception e) {
Log.e(TAG, "视频轨道创建失败", e);
if (listener != null) {
listener.onError("视频轨道创建失败: " + e.getMessage());
}
}
} else {
Log.d(TAG, "语音通话,跳过视频轨道创建");
}
Log.d(TAG, "本地媒体流创建完成: audioTrack=" + (localAudioTrack != null) + ", videoTrack=" + (localVideoTrack != null));
}
/**
* 创建视频轨道
*/
private void createVideoTrack() {
Log.d(TAG, "创建视频轨道");
videoCapturer = createCameraCapturer();
if (videoCapturer == null) {
Log.e(TAG, "无法创建摄像头捕获器");
if (listener != null) {
listener.onError("无法访问摄像头");
}
return;
}
surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", eglBase.getEglBaseContext());
videoSource = peerConnectionFactory.createVideoSource(videoCapturer.isScreencast());
videoCapturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver());
// 启动摄像头捕获 (720p, 30fps)
videoCapturer.startCapture(1280, 720, 30);
localVideoTrack = peerConnectionFactory.createVideoTrack("ARDAMSv0", videoSource);
localVideoTrack.setEnabled(true);
// 添加到本地渲染器
if (localRenderer != null) {
localVideoTrack.addSink(localRenderer);
}
Log.d(TAG, "视频轨道创建成功");
}
/**
* 创建摄像头捕获器
*/
private VideoCapturer createCameraCapturer() {
CameraEnumerator enumerator;
if (Camera2Enumerator.isSupported(context)) {
enumerator = new Camera2Enumerator(context);
} else {
enumerator = new Camera1Enumerator(true);
}
String[] deviceNames = enumerator.getDeviceNames();
// 优先使用前置摄像头
for (String deviceName : deviceNames) {
if (useFrontCamera && enumerator.isFrontFacing(deviceName)) {
VideoCapturer capturer = enumerator.createCapturer(deviceName, null);
if (capturer != null) {
Log.d(TAG, "使用前置摄像头: " + deviceName);
return capturer;
}
}
}
// 如果没有前置摄像头使用后置
for (String deviceName : deviceNames) {
if (!enumerator.isFrontFacing(deviceName)) {
VideoCapturer capturer = enumerator.createCapturer(deviceName, null);
if (capturer != null) {
Log.d(TAG, "使用后置摄像头: " + deviceName);
return capturer;
}
}
}
return null;
}
/**
* 创建 PeerConnection
*/
public void createPeerConnection() {
Log.d(TAG, "创建 PeerConnection");
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(getIceServers());
config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
config.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
peerConnection = peerConnectionFactory.createPeerConnection(config, new PeerConnection.Observer() {
@Override
public void onSignalingChange(PeerConnection.SignalingState signalingState) {
Log.d(TAG, "onSignalingChange: " + signalingState);
}
@Override
public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
Log.d(TAG, "========== ICE 连接状态变化 ==========");
Log.d(TAG, "ICE状态: " + iceConnectionState);
// 打印详细的连接信息
if (peerConnection != null) {
Log.d(TAG, "信令状态: " + peerConnection.signalingState());
Log.d(TAG, "连接状态: " + peerConnection.connectionState());
}
if (listener != null) {
listener.onIceConnectionChange(iceConnectionState);
}
}
@Override
public void onIceConnectionReceivingChange(boolean b) {
Log.d(TAG, "onIceConnectionReceivingChange: " + b);
}
@Override
public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {
Log.d(TAG, "onIceGatheringChange: " + iceGatheringState);
}
@Override
public void onIceCandidate(IceCandidate iceCandidate) {
Log.d(TAG, "========== 生成 ICE Candidate ==========");
Log.d(TAG, "sdpMid: " + iceCandidate.sdpMid);
Log.d(TAG, "sdpMLineIndex: " + iceCandidate.sdpMLineIndex);
Log.d(TAG, "candidate: " + iceCandidate.sdp);
if (listener != null) {
listener.onIceCandidate(iceCandidate);
}
}
@Override
public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {
Log.d(TAG, "onIceCandidatesRemoved");
}
@Override
public void onAddStream(MediaStream mediaStream) {
Log.d(TAG, "========== 收到远程媒体流 ==========");
Log.d(TAG, "streamId: " + mediaStream.getId());
Log.d(TAG, "音频轨道数: " + mediaStream.audioTracks.size());
Log.d(TAG, "视频轨道数: " + mediaStream.videoTracks.size());
if (listener != null) {
listener.onRemoteStream(mediaStream);
}
// 添加远程音频轨道
if (mediaStream.audioTracks.size() > 0) {
Log.d(TAG, "启用远程音频轨道");
mediaStream.audioTracks.get(0).setEnabled(true);
}
// 添加远程视频到渲染器
if (mediaStream.videoTracks.size() > 0 && remoteRenderer != null) {
Log.d(TAG, "添加远程视频到渲染器");
mediaStream.videoTracks.get(0).addSink(remoteRenderer);
}
}
@Override
public void onRemoveStream(MediaStream mediaStream) {
Log.d(TAG, "onRemoveStream: " + mediaStream.getId());
}
@Override
public void onDataChannel(DataChannel dataChannel) {
Log.d(TAG, "onDataChannel");
}
@Override
public void onRenegotiationNeeded() {
Log.d(TAG, "onRenegotiationNeeded");
}
@Override
public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
Log.d(TAG, "========== onAddTrack ==========");
if (rtpReceiver.track() != null) {
Log.d(TAG, "轨道类型: " + rtpReceiver.track().kind());
Log.d(TAG, "轨道ID: " + rtpReceiver.track().id());
// 处理远程视频轨道
if (rtpReceiver.track().kind().equals("video")) {
VideoTrack remoteVideoTrack = (VideoTrack) rtpReceiver.track();
Log.d(TAG, "收到远程视频轨道,添加到渲染器");
if (remoteRenderer != null) {
remoteVideoTrack.addSink(remoteRenderer);
Log.d(TAG, "远程视频已添加到渲染器");
} else {
Log.e(TAG, "remoteRenderer 为空,无法显示远程视频");
}
}
}
}
});
// 添加本地轨道到 PeerConnection
Log.d(TAG, "========== 添加本地轨道 ==========");
Log.d(TAG, "localAudioTrack=" + (localAudioTrack != null) + ", localVideoTrack=" + (localVideoTrack != null));
if (localAudioTrack != null) {
peerConnection.addTrack(localAudioTrack);
Log.d(TAG, "已添加本地音频轨道");
} else {
Log.w(TAG, "本地音频轨道为空,无法添加");
}
if (localVideoTrack != null) {
peerConnection.addTrack(localVideoTrack);
Log.d(TAG, "已添加本地视频轨道");
} else {
Log.w(TAG, "本地视频轨道为空,无法添加(如果是视频通话则有问题)");
}
Log.d(TAG, "PeerConnection 创建成功, isVideoCall=" + isVideoCall);
}
/**
* 创建 Offer (主叫方调用)
*/
public void createOffer() {
Log.d(TAG, "创建 Offer");
MediaConstraints constraints = new MediaConstraints();
constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"));
constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", isVideoCall ? "true" : "false"));
peerConnection.createOffer(new SdpObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
Log.d(TAG, "Offer 创建成功");
peerConnection.setLocalDescription(new SdpObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {}
@Override
public void onSetSuccess() {
Log.d(TAG, "本地 SDP 设置成功");
if (listener != null) {
listener.onOfferCreated(sessionDescription);
}
}
@Override
public void onCreateFailure(String s) {}
@Override
public void onSetFailure(String s) {
Log.e(TAG, "设置本地 SDP 失败: " + s);
}
}, sessionDescription);
}
@Override
public void onSetSuccess() {}
@Override
public void onCreateFailure(String s) {
Log.e(TAG, "创建 Offer 失败: " + s);
if (listener != null) {
listener.onError("创建 Offer 失败: " + s);
}
}
@Override
public void onSetFailure(String s) {}
}, constraints);
}
/**
* 创建 Answer (被叫方调用)
*/
public void createAnswer() {
Log.d(TAG, "创建 Answer");
MediaConstraints constraints = new MediaConstraints();
constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"));
constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", isVideoCall ? "true" : "false"));
peerConnection.createAnswer(new SdpObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
Log.d(TAG, "Answer 创建成功");
peerConnection.setLocalDescription(new SdpObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {}
@Override
public void onSetSuccess() {
Log.d(TAG, "本地 SDP 设置成功");
if (listener != null) {
listener.onAnswerCreated(sessionDescription);
}
}
@Override
public void onCreateFailure(String s) {}
@Override
public void onSetFailure(String s) {
Log.e(TAG, "设置本地 SDP 失败: " + s);
}
}, sessionDescription);
}
@Override
public void onSetSuccess() {}
@Override
public void onCreateFailure(String s) {
Log.e(TAG, "创建 Answer 失败: " + s);
if (listener != null) {
listener.onError("创建 Answer 失败: " + s);
}
}
@Override
public void onSetFailure(String s) {}
}, constraints);
}
/**
* 设置远程 SDP
*/
public void setRemoteDescription(SessionDescription sdp) {
Log.d(TAG, "设置远程 SDP, type=" + sdp.type);
peerConnection.setRemoteDescription(new SdpObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {}
@Override
public void onSetSuccess() {
Log.d(TAG, "远程 SDP 设置成功");
// 如果是 Offer需要创建 Answer
if (sdp.type == SessionDescription.Type.OFFER) {
createAnswer();
}
}
@Override
public void onCreateFailure(String s) {}
@Override
public void onSetFailure(String s) {
Log.e(TAG, "设置远程 SDP 失败: " + s);
if (listener != null) {
listener.onError("设置远程 SDP 失败: " + s);
}
}
}, sdp);
}
/**
* 添加 ICE Candidate
*/
public void addIceCandidate(IceCandidate candidate) {
Log.d(TAG, "========== 添加远程 ICE Candidate ==========");
Log.d(TAG, "sdpMid: " + candidate.sdpMid);
Log.d(TAG, "sdpMLineIndex: " + candidate.sdpMLineIndex);
Log.d(TAG, "candidate: " + candidate.sdp);
if (peerConnection != null) {
boolean result = peerConnection.addIceCandidate(candidate);
Log.d(TAG, "添加结果: " + result);
} else {
Log.e(TAG, "peerConnection 为 null无法添加 ICE Candidate");
}
}
/**
* 切换静音
*/
public void setMuted(boolean muted) {
this.isMuted = muted;
if (localAudioTrack != null) {
localAudioTrack.setEnabled(!muted);
}
Log.d(TAG, "静音状态: " + muted);
}
/**
* 切换视频
*/
public void setVideoEnabled(boolean enabled) {
this.isVideoEnabled = enabled;
if (localVideoTrack != null) {
localVideoTrack.setEnabled(enabled);
}
Log.d(TAG, "视频状态: " + enabled);
}
/**
* 切换摄像头
*/
public void switchCamera() {
if (videoCapturer instanceof org.webrtc.CameraVideoCapturer) {
((org.webrtc.CameraVideoCapturer) videoCapturer).switchCamera(null);
useFrontCamera = !useFrontCamera;
if (localRenderer != null) {
localRenderer.setMirror(useFrontCamera);
}
Log.d(TAG, "切换摄像头, 使用前置: " + useFrontCamera);
}
}
/**
* 释放资源
*/
public void release() {
Log.d(TAG, "释放 WebRTC 资源");
if (videoCapturer != null) {
try {
videoCapturer.stopCapture();
} catch (InterruptedException e) {
Log.e(TAG, "停止摄像头捕获失败", e);
}
videoCapturer.dispose();
videoCapturer = null;
}
if (surfaceTextureHelper != null) {
surfaceTextureHelper.dispose();
surfaceTextureHelper = null;
}
if (localVideoTrack != null) {
localVideoTrack.dispose();
localVideoTrack = null;
}
if (videoSource != null) {
videoSource.dispose();
videoSource = null;
}
if (localAudioTrack != null) {
localAudioTrack.dispose();
localAudioTrack = null;
}
if (audioSource != null) {
audioSource.dispose();
audioSource = null;
}
if (peerConnection != null) {
peerConnection.close();
peerConnection = null;
}
if (peerConnectionFactory != null) {
peerConnectionFactory.dispose();
peerConnectionFactory = null;
}
if (localRenderer != null) {
localRenderer.release();
}
if (remoteRenderer != null) {
remoteRenderer.release();
}
if (eglBase != null) {
eglBase.release();
eglBase = null;
}
Log.d(TAG, "WebRTC 资源释放完成");
}
public boolean isMuted() {
return isMuted;
}
public boolean isVideoEnabled() {
return isVideoEnabled;
}
public EglBase getEglBase() {
return eglBase;
}
}

View File

@ -1,52 +0,0 @@
package com.example.livestreaming.call;
/**
* WebRTC 配置
* 部署时修改这里的服务器地址
*/
public class WebRTCConfig {
// ============ STUN 服务器 ============
// STUN 用于获取设备的公网IP帮助建立P2P连接
public static final String[] STUN_SERVERS = {
"stun:stun.l.google.com:19302", // Google STUN国内可能不稳定
"stun:stun.qq.com:3478", // 腾讯 STUN国内推荐
"stun:stun.miwifi.com:3478" // 小米 STUN国内推荐
};
// ============ TURN 服务器 ============
// TURN 用于在P2P连接失败时进行中继转发
// 你的服务器TURN地址
public static final String TURN_SERVER_URL = "turn:1.15.149.240:3478";
// TURN 服务器用户名
public static final String TURN_USERNAME = "turnuser";
// TURN 服务器密码
public static final String TURN_PASSWORD = "TurnPass123456";
// ============ 使用说明 ============
/*
* 局域网测试
* - 不需要修改当前配置即可使用
*
* 部署到公网服务器
* 1. 在服务器安装 coturn (TURN服务器)
* 2. 修改上面的配置
* TURN_SERVER_URL = "turn:你的服务器IP:3478"
* TURN_USERNAME = "你设置的用户名"
* TURN_PASSWORD = "你设置的密码"
*
* 宝塔安装 coturn 步骤
* 1. SSH执行: yum install -y coturn (CentOS) apt install -y coturn (Ubuntu)
* 2. 编辑 /etc/turnserver.conf:
* listening-port=3478
* external-ip=你的公网IP
* realm=你的公网IP
* lt-cred-mech
* user=用户名:密码
* 3. 宝塔放行端口: 3478(TCP/UDP), 49152-65535(UDP)
* 4. 启动: systemctl start coturn
*/
}

View File

@ -1,32 +1,39 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
public class ConversationResponse {
private Integer id;
private Integer targetUserId;
private String targetUserName;
private String targetUserAvatar;
@SerializedName("id")
private String id;
@SerializedName("title")
private String title;
@SerializedName("lastMessage")
private String lastMessage;
private String lastMessageTime;
@SerializedName("timeText")
private String timeText;
@SerializedName("unreadCount")
private Integer unreadCount;
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
@SerializedName("muted")
private Boolean muted;
public Integer getTargetUserId() { return targetUserId; }
public void setTargetUserId(Integer targetUserId) { this.targetUserId = targetUserId; }
@SerializedName("avatarUrl")
private String avatarUrl;
public String getTargetUserName() { return targetUserName; }
public void setTargetUserName(String targetUserName) { this.targetUserName = targetUserName; }
public String getTargetUserAvatar() { return targetUserAvatar; }
public void setTargetUserAvatar(String targetUserAvatar) { this.targetUserAvatar = targetUserAvatar; }
@SerializedName("otherUserId")
private Integer otherUserId;
public String getId() { return id; }
public String getTitle() { return title; }
public String getLastMessage() { return lastMessage; }
public void setLastMessage(String lastMessage) { this.lastMessage = lastMessage; }
public String getLastMessageTime() { return lastMessageTime; }
public void setLastMessageTime(String lastMessageTime) { this.lastMessageTime = lastMessageTime; }
public String getTimeText() { return timeText; }
public Integer getUnreadCount() { return unreadCount; }
public void setUnreadCount(Integer unreadCount) { this.unreadCount = unreadCount; }
public Boolean getMuted() { return muted; }
public String getAvatarUrl() { return avatarUrl; }
public Integer getOtherUserId() { return otherUserId; }
}

View File

@ -1,26 +1,32 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
import java.util.List;
public class PageResponse<T> {
@SerializedName("list")
private List<T> list;
@SerializedName("total")
private Long total;
@SerializedName("page")
private Integer page;
@SerializedName("limit")
private Integer limit;
private Integer total;
@SerializedName("totalPage")
private Integer totalPage;
public List<T> getList() { return list; }
public void setList(List<T> list) { this.list = list; }
public Long getTotal() { return total; }
public Integer getPage() { return page; }
public void setPage(Integer page) { this.page = page; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public Integer getTotal() { return total; }
public void setTotal(Integer total) { this.total = total; }
public Integer getTotalPage() { return totalPage; }
public void setTotalPage(Integer totalPage) { this.totalPage = totalPage; }
public boolean hasMore() {
return page != null && totalPage != null && page < totalPage;
}
}

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#8B4513" />
<corners android:radius="4dp" />
</shape>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFEF3" />
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="#FFD700" />
</shape>

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFF8E1" />
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="#FFCC00"
android:dashWidth="4dp"
android:dashGap="2dp" />
</shape>

View File

@ -5,7 +5,7 @@
android:layout_height="match_parent"
android:background="#1A1A2E">
<!-- 背景模糊头像(语音通话时显示) -->
<!-- 背景模糊头像 -->
<ImageView
android:id="@+id/ivBackgroundAvatar"
android:layout_width="match_parent"
@ -13,22 +13,21 @@
android:scaleType="centerCrop"
android:alpha="0.3" />
<!-- 视频通话时的远程视频容器 -->
<FrameLayout
android:id="@+id/layoutRemoteVideo"
<!-- 视频通话时的远程视频 -->
<SurfaceView
android:id="@+id/remoteVideoView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<!-- 视频通话时的本地视频容器(小窗口) -->
<FrameLayout
android:id="@+id/layoutLocalVideo"
<!-- 视频通话时的本地视频(小窗口) -->
<SurfaceView
android:id="@+id/localVideoView"
android:layout_width="120dp"
android:layout_height="160dp"
android:layout_gravity="top|end"
android:layout_marginTop="80dp"
android:layout_marginEnd="16dp"
android:background="#333333"
android:visibility="gone" />
<!-- 主内容区域 -->

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="许下你的愿望"
android:textSize="18sp"
android:textStyle="bold"
android:gravity="center"
android:layout_marginBottom="16dp" />
<EditText
android:id="@+id/editWish"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="写下你的愿望..."
android:minLines="3"
android:gravity="top" />
</LinearLayout>

View File

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:src="@android:drawable/btn_star_big_on" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="愿望已挂上许愿树"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginTop="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="愿你心想事成"
android:textColor="#666"
android:layout_marginTop="8dp" />
</LinearLayout>

View File

@ -1,81 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_avatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@mipmap/ic_launcher" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/tv_user_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#999"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/tv_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_like_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableStart="@android:drawable/btn_star"
android:drawablePadding="4dp"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_comment_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:drawableStart="@android:drawable/ic_menu_edit"
android:drawablePadding="4dp"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@ -1,82 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
android:layout_margin="6dp"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<FrameLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/coverImage"
android:layout_width="match_parent"
android:layout_height="120dp"
android:scaleType="centerCrop" />
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/bg_cover_placeholder"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="H,4:3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/liveBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_margin="8dp"
android:background="@android:color/holo_red_light"
android:paddingHorizontal="6dp"
android:paddingVertical="2dp"
android:text="直播中"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:text="LIVE"
android:textColor="@android:color/white"
android:textSize="10sp"
android:visibility="gone" />
android:textSize="11sp"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/coverImage"
app:layout_constraintTop_toTopOf="@id/coverImage" />
<LinearLayout
android:layout_width="match_parent"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/infoContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="#80000000"
android:orientation="vertical"
android:padding="8dp">
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:paddingTop="8dp"
android:paddingBottom="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/coverImage">
<TextView
android:id="@+id/roomTitle"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@android:color/white"
android:textSize="14sp" />
android:maxLines="2"
android:text="王者荣耀陪练"
android:textColor="#111111"
android:textSize="13sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:layout_width="match_parent"
<ImageView
android:id="@+id/streamerAvatar"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginTop="8dp"
android:background="@drawable/bg_avatar_circle"
android:padding="3dp"
android:src="@drawable/ic_account_circle_24"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomTitle" />
<TextView
android:id="@+id/streamerName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal">
android:layout_marginStart="6dp"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="虚拟主播"
android:textColor="#666666"
android:textSize="12sp"
app:layout_constraintEnd_toStartOf="@id/likeIcon"
app:layout_constraintStart_toEndOf="@id/streamerAvatar"
app:layout_constraintTop_toBottomOf="@id/roomTitle" />
<TextView
android:id="@+id/streamerName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#CCC"
android:textSize="12sp" />
<ImageView
android:id="@+id/likeIcon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginTop="8dp"
android:src="@drawable/ic_heart_24"
app:layout_constraintEnd_toStartOf="@id/likeCount"
app:layout_constraintTop_toBottomOf="@id/roomTitle" />
<ImageView
android:id="@+id/likeIcon"
android:layout_width="14dp"
android:layout_height="14dp"
android:src="@android:drawable/btn_star_big_on"
android:visibility="gone" />
<TextView
android:id="@+id/likeCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginTop="8dp"
android:text="184"
android:textColor="#888888"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomTitle" />
<TextView
android:id="@+id/likeCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:textColor="#CCC"
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
</androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -1,8 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
# 使用 Android Studio 自带的 JDK 17
org.gradle.java.home=C:/Program Files/Android/Android Studio/jbr
systemProp.gradle.wrapperUser=myuser
systemProp.gradle.wrapperPassword=mypassword

View File

View File

@ -1,79 +0,0 @@
@echo off
chcp 65001 >nul
setlocal EnableDelayedExpansion
REM ============================================
REM 直播系统一键部署脚本 (Windows版)
REM 服务器地址: 1.15.149.240
REM ============================================
set SERVER_IP=1.15.149.240
set SERVER_USER=root
set DEPLOY_PATH=/opt/zhibo
echo ==========================================
echo 直播系统部署脚本 (Windows版)
echo 目标服务器: %SERVER_IP%
echo ==========================================
echo.
REM 检查scp和ssh命令
where scp >nul 2>&1
if %errorlevel% neq 0 (
echo 错误: 未找到scp命令
echo 请安装OpenSSH客户端或使用Git Bash运行deploy-to-server.sh
pause
exit /b 1
)
echo [1/6] 测试SSH连接...
ssh -o ConnectTimeout=10 %SERVER_USER%@%SERVER_IP% "echo SSH连接成功"
if %errorlevel% neq 0 (
echo 错误: 无法连接到服务器
pause
exit /b 1
)
echo.
echo [2/6] 创建服务器目录结构...
ssh %SERVER_USER%@%SERVER_IP% "mkdir -p /opt/zhibo/admin-api /opt/zhibo/front-api /opt/zhibo/admin-web /opt/zhibo/logs /opt/zhibo/scripts"
echo.
echo [3/6] 上传后端JAR包...
echo - 上传 Admin API...
scp Zhibo/zhibo-h/crmeb-admin/target/Crmeb-admin.jar %SERVER_USER%@%SERVER_IP%:/opt/zhibo/admin-api/
echo - 上传 Front API...
scp Zhibo/zhibo-h/crmeb-front/target/Crmeb-front.jar %SERVER_USER%@%SERVER_IP%:/opt/zhibo/front-api/
echo.
echo [4/6] 上传前端管理界面...
scp -r Zhibo/admin/dist/* %SERVER_USER%@%SERVER_IP%:/opt/zhibo/admin-web/
echo.
echo [5/6] 创建服务启动脚本...
ssh %SERVER_USER%@%SERVER_IP% "cat > /opt/zhibo/scripts/start-admin-api.sh" < server-scripts/start-admin-api.sh
ssh %SERVER_USER%@%SERVER_IP% "cat > /opt/zhibo/scripts/start-front-api.sh" < server-scripts/start-front-api.sh
ssh %SERVER_USER%@%SERVER_IP% "chmod +x /opt/zhibo/scripts/*.sh"
echo.
echo [6/6] 上传Nginx配置...
scp server-scripts/zhibo.nginx.conf %SERVER_USER%@%SERVER_IP%:/etc/nginx/conf.d/zhibo.conf
ssh %SERVER_USER%@%SERVER_IP% "nginx -t && systemctl reload nginx"
echo.
echo ==========================================
echo 部署完成!
echo ==========================================
echo.
echo 请在服务器上执行以下命令启动服务:
echo ssh %SERVER_USER%@%SERVER_IP%
echo cd /opt/zhibo/scripts
echo ./start-all.sh
echo.
echo 服务访问地址:
echo - 管理后台: http://%SERVER_IP%
echo - Admin API: http://%SERVER_IP%:30001
echo - Front API: http://%SERVER_IP%:8081
echo.
pause

View File

@ -1,269 +0,0 @@
#!/bin/bash
# ============================================
# 直播系统一键部署脚本
# 服务器地址: 1.15.149.240
# ============================================
set -e
# 配置变量
SERVER_IP="1.15.149.240"
SERVER_USER="root"
DEPLOY_PATH="/opt/zhibo"
ADMIN_API_PORT=30001
FRONT_API_PORT=8081
echo "=========================================="
echo " 直播系统部署脚本"
echo " 目标服务器: $SERVER_IP"
echo "=========================================="
# 检查SSH连接
echo ""
echo "[1/6] 检查SSH连接..."
ssh -o ConnectTimeout=10 ${SERVER_USER}@${SERVER_IP} "echo 'SSH连接成功'" || {
echo "错误: 无法连接到服务器 ${SERVER_IP}"
echo "请确保:"
echo " 1. 服务器IP正确"
echo " 2. SSH密钥已配置或准备好密码"
exit 1
}
# 在服务器上创建目录结构
echo ""
echo "[2/6] 创建服务器目录结构..."
ssh ${SERVER_USER}@${SERVER_IP} << 'ENDSSH'
mkdir -p /opt/zhibo/admin-api
mkdir -p /opt/zhibo/front-api
mkdir -p /opt/zhibo/admin-web
mkdir -p /opt/zhibo/logs
mkdir -p /opt/zhibo/scripts
echo "目录创建完成"
ENDSSH
# 上传后端JAR包
echo ""
echo "[3/6] 上传后端JAR包..."
echo " - 上传 Admin API (Crmeb-admin.jar)..."
scp Zhibo/zhibo-h/crmeb-admin/target/Crmeb-admin.jar ${SERVER_USER}@${SERVER_IP}:/opt/zhibo/admin-api/
echo " - 上传 Front API (Crmeb-front.jar)..."
scp Zhibo/zhibo-h/crmeb-front/target/Crmeb-front.jar ${SERVER_USER}@${SERVER_IP}:/opt/zhibo/front-api/
# 上传前端文件
echo ""
echo "[4/6] 上传前端管理界面..."
scp -r Zhibo/admin/dist/* ${SERVER_USER}@${SERVER_IP}:/opt/zhibo/admin-web/
# 创建启动脚本
echo ""
echo "[5/6] 创建服务启动脚本..."
ssh ${SERVER_USER}@${SERVER_IP} << 'ENDSSH'
# 创建Admin API启动脚本
cat > /opt/zhibo/scripts/start-admin-api.sh << 'EOF'
#!/bin/bash
APP_NAME="Crmeb-admin"
APP_PATH="/opt/zhibo/admin-api"
LOG_PATH="/opt/zhibo/logs"
JAR_FILE="${APP_PATH}/${APP_NAME}.jar"
# 停止旧进程
pid=$(ps -ef | grep "${APP_NAME}.jar" | grep -v grep | awk '{print $2}')
if [ -n "$pid" ]; then
echo "停止旧进程: $pid"
kill -9 $pid
sleep 2
fi
# 启动新进程
if [ -f "$JAR_FILE" ]; then
echo "启动 ${APP_NAME}..."
nohup java -Xms512m -Xmx1024m -jar $JAR_FILE \
--spring.redis.host=127.0.0.1 \
> ${LOG_PATH}/admin-api.log 2>&1 &
echo "Admin API 启动成功,端口: 30001"
else
echo "错误: JAR文件不存在: $JAR_FILE"
exit 1
fi
EOF
# 创建Front API启动脚本
cat > /opt/zhibo/scripts/start-front-api.sh << 'EOF'
#!/bin/bash
APP_NAME="Crmeb-front"
APP_PATH="/opt/zhibo/front-api"
LOG_PATH="/opt/zhibo/logs"
JAR_FILE="${APP_PATH}/${APP_NAME}.jar"
# 停止旧进程
pid=$(ps -ef | grep "${APP_NAME}.jar" | grep -v grep | awk '{print $2}')
if [ -n "$pid" ]; then
echo "停止旧进程: $pid"
kill -9 $pid
sleep 2
fi
# 启动新进程
if [ -f "$JAR_FILE" ]; then
echo "启动 ${APP_NAME}..."
nohup java -Xms512m -Xmx1024m -jar $JAR_FILE \
--spring.redis.host=127.0.0.1 \
> ${LOG_PATH}/front-api.log 2>&1 &
echo "Front API 启动成功,端口: 8081"
else
echo "错误: JAR文件不存在: $JAR_FILE"
exit 1
fi
EOF
# 创建停止脚本
cat > /opt/zhibo/scripts/stop-all.sh << 'EOF'
#!/bin/bash
echo "停止所有服务..."
pkill -f "Crmeb-admin.jar" 2>/dev/null && echo "Admin API 已停止" || echo "Admin API 未运行"
pkill -f "Crmeb-front.jar" 2>/dev/null && echo "Front API 已停止" || echo "Front API 未运行"
echo "完成"
EOF
# 创建一键启动脚本
cat > /opt/zhibo/scripts/start-all.sh << 'EOF'
#!/bin/bash
echo "=========================================="
echo " 启动所有直播系统服务"
echo "=========================================="
cd /opt/zhibo/scripts
./start-admin-api.sh
sleep 5
./start-front-api.sh
echo ""
echo "所有服务启动完成!"
echo " - Admin API: http://localhost:30001"
echo " - Front API: http://localhost:8081"
EOF
# 创建状态检查脚本
cat > /opt/zhibo/scripts/status.sh << 'EOF'
#!/bin/bash
echo "=========================================="
echo " 服务状态检查"
echo "=========================================="
echo ""
echo "Admin API (端口 30001):"
if pgrep -f "Crmeb-admin.jar" > /dev/null; then
echo " 状态: 运行中"
pid=$(pgrep -f "Crmeb-admin.jar")
echo " PID: $pid"
else
echo " 状态: 未运行"
fi
echo ""
echo "Front API (端口 8081):"
if pgrep -f "Crmeb-front.jar" > /dev/null; then
echo " 状态: 运行中"
pid=$(pgrep -f "Crmeb-front.jar")
echo " PID: $pid"
else
echo " 状态: 未运行"
fi
echo ""
echo "端口监听状态:"
netstat -tlnp 2>/dev/null | grep -E "30001|8081" || ss -tlnp | grep -E "30001|8081"
EOF
chmod +x /opt/zhibo/scripts/*.sh
echo "启动脚本创建完成"
ENDSSH
# 创建Nginx配置
echo ""
echo "[6/6] 配置Nginx..."
ssh ${SERVER_USER}@${SERVER_IP} << 'ENDSSH'
# 检查Nginx是否安装
if ! command -v nginx &> /dev/null; then
echo "Nginx未安装正在安装..."
apt-get update && apt-get install -y nginx || yum install -y nginx
fi
# 创建Nginx配置
cat > /etc/nginx/conf.d/zhibo.conf << 'EOF'
# 直播系统 - 管理后台前端
server {
listen 80;
server_name admin.zhibo.local; # 可以改成你的域名
root /opt/zhibo/admin-web;
index index.html;
# 前端静态文件
location / {
try_files $uri $uri/ /index.html;
}
# 代理Admin API
location /api/admin/ {
proxy_pass http://127.0.0.1:30001/api/admin/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 代理公共API
location /api/public/ {
proxy_pass http://127.0.0.1:30001/api/public/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
# 直播系统 - 前端API (供APP调用)
server {
listen 8080;
server_name _;
# 代理Front API
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
}
EOF
# 测试Nginx配置
nginx -t && systemctl reload nginx
echo "Nginx配置完成"
ENDSSH
echo ""
echo "=========================================="
echo " 部署完成!"
echo "=========================================="
echo ""
echo "接下来请在服务器上执行以下命令启动服务:"
echo ""
echo " ssh ${SERVER_USER}@${SERVER_IP}"
echo " cd /opt/zhibo/scripts"
echo " ./start-all.sh"
echo ""
echo "服务访问地址:"
echo " - 管理后台: http://${SERVER_IP}"
echo " - Admin API: http://${SERVER_IP}:30001"
echo " - Front API: http://${SERVER_IP}:8081 (或通过Nginx 8080端口)"
echo ""
echo "常用命令:"
echo " - 启动所有服务: /opt/zhibo/scripts/start-all.sh"
echo " - 停止所有服务: /opt/zhibo/scripts/stop-all.sh"
echo " - 查看服务状态: /opt/zhibo/scripts/status.sh"
echo " - 查看日志: tail -f /opt/zhibo/logs/admin-api.log"
echo " tail -f /opt/zhibo/logs/front-api.log"
echo ""

Binary file not shown.

View File

@ -1,25 +0,0 @@
# ==========================================
# 直播服务 Docker 环境配置
# 复制此文件为 .env 并修改配置
# ==========================================
# ========== 服务器公网地址 ==========
# 你的服务器公网 IP
PUBLIC_SRS_HOST=1.15.149.240
# ========== 端口配置 ==========
# API 服务端口
API_EXPOSE_PORT=25001
# SRS RTMP 端口(推流用)
SRS_RTMP_EXPOSE_PORT=25002
# SRS HTTP 端口(拉流用)
SRS_HTTP_EXPOSE_PORT=25003
# SRS API 端口
SRS_API_EXPOSE_PORT=1985
# ========== 公网端口(如果使用端口映射,修改这里)==========
PUBLIC_SRS_RTMP_PORT=25002
PUBLIC_SRS_HTTP_PORT=25003

View File

@ -1,46 +0,0 @@
@echo off
chcp 65001 >nul
REM ==========================================
REM 直播服务 Docker 部署脚本 (Windows)
REM ==========================================
echo ==========================================
echo 直播服务 Docker 部署
echo ==========================================
REM 检查 .env 文件
if not exist ".env" (
echo ❌ 错误: 未找到 .env 文件
echo 请复制 .env.example 为 .env 并修改配置
echo copy .env.example .env
pause
exit /b 1
)
echo.
echo 🔨 构建镜像...
docker-compose build
echo.
echo 🚀 启动服务...
docker-compose up -d
echo.
echo ✅ 部署完成!
echo.
echo ==========================================
echo 服务状态
echo ==========================================
docker-compose ps
echo.
echo ==========================================
echo 常用命令
echo ==========================================
echo 查看日志: docker-compose logs -f
echo 停止服务: docker-compose down
echo 重启服务: docker-compose restart
echo 查看状态: docker-compose ps
echo.
pause

View File

@ -1,70 +0,0 @@
#!/bin/bash
# ==========================================
# 直播服务 Docker 部署脚本
# ==========================================
set -e
echo "=========================================="
echo " 直播服务 Docker 部署"
echo "=========================================="
# 检查 .env 文件
if [ ! -f ".env" ]; then
echo "❌ 错误: 未找到 .env 文件"
echo "请复制 .env.example 为 .env 并修改配置"
echo " cp .env.example .env"
echo " nano .env"
exit 1
fi
# 加载环境变量
source .env
echo ""
echo "📋 当前配置:"
echo " - 公网地址: ${PUBLIC_SRS_HOST:-未设置}"
echo " - API 端口: ${API_EXPOSE_PORT:-25001}"
echo " - RTMP 端口: ${SRS_RTMP_EXPOSE_PORT:-25002}"
echo " - HTTP 端口: ${SRS_HTTP_EXPOSE_PORT:-25003}"
echo ""
# 确认部署
read -p "是否继续部署? (y/n): " confirm
if [ "$confirm" != "y" ]; then
echo "已取消部署"
exit 0
fi
echo ""
echo "🔨 构建镜像..."
docker-compose build
echo ""
echo "🚀 启动服务..."
docker-compose up -d
echo ""
echo "✅ 部署完成!"
echo ""
echo "=========================================="
echo " 服务状态"
echo "=========================================="
docker-compose ps
echo ""
echo "=========================================="
echo " 访问地址"
echo "=========================================="
echo " API 服务: http://${PUBLIC_SRS_HOST:-localhost}:${API_EXPOSE_PORT:-25001}"
echo " RTMP 推流: rtmp://${PUBLIC_SRS_HOST:-localhost}:${SRS_RTMP_EXPOSE_PORT:-25002}/live/[streamKey]"
echo " HTTP 拉流: http://${PUBLIC_SRS_HOST:-localhost}:${SRS_HTTP_EXPOSE_PORT:-25003}/live/[streamKey].flv"
echo ""
echo "=========================================="
echo " 常用命令"
echo "=========================================="
echo " 查看日志: docker-compose logs -f"
echo " 停止服务: docker-compose down"
echo " 重启服务: docker-compose restart"
echo " 查看状态: docker-compose ps"
echo ""

View File

@ -1,278 +0,0 @@
# 直播系统部署指南
## 服务器信息
- **IP地址**: 1.15.149.240
- **部署目录**: /opt/zhibo
## 需要部署的服务
| 服务 | 端口 | 说明 |
|------|------|------|
| Admin API | 30001 | 管理后台API |
| Front API | 8081 | 前端/APP API |
| Admin Web | 80 | 管理后台界面 |
---
## 第一步:上传文件到服务器
### 方法1使用SCP命令推荐
打开PowerShell或CMD在项目根目录执行
```powershell
# 1. 创建服务器目录
ssh root@1.15.149.240 "mkdir -p /opt/zhibo/{admin-api,front-api,admin-web,logs,scripts}"
# 2. 上传Admin API JAR包
scp Zhibo/zhibo-h/crmeb-admin/target/Crmeb-admin.jar root@1.15.149.240:/opt/zhibo/admin-api/
# 3. 上传Front API JAR包
scp Zhibo/zhibo-h/crmeb-front/target/Crmeb-front.jar root@1.15.149.240:/opt/zhibo/front-api/
# 4. 上传前端文件
scp -r Zhibo/admin/dist/* root@1.15.149.240:/opt/zhibo/admin-web/
```
### 方法2使用SFTP工具
使用FileZilla、WinSCP等工具连接服务器手动上传
- `Zhibo/zhibo-h/crmeb-admin/target/Crmeb-admin.jar``/opt/zhibo/admin-api/`
- `Zhibo/zhibo-h/crmeb-front/target/Crmeb-front.jar``/opt/zhibo/front-api/`
- `Zhibo/admin/dist/` 目录下所有文件 → `/opt/zhibo/admin-web/`
---
## 第二步SSH登录服务器配置
```bash
ssh root@1.15.149.240
```
### 2.1 创建启动脚本
```bash
# 创建Admin API启动脚本
cat > /opt/zhibo/scripts/start-admin-api.sh << 'EOF'
#!/bin/bash
APP_NAME="Crmeb-admin"
JAR_FILE="/opt/zhibo/admin-api/${APP_NAME}.jar"
LOG_FILE="/opt/zhibo/logs/admin-api.log"
# 停止旧进程
pid=$(pgrep -f "${APP_NAME}.jar")
[ -n "$pid" ] && kill -9 $pid && echo "停止旧进程: $pid" && sleep 2
# 启动
nohup java -Xms512m -Xmx1024m -jar $JAR_FILE \
--spring.redis.host=127.0.0.1 \
> $LOG_FILE 2>&1 &
echo "Admin API 启动成功,端口: 30001"
EOF
# 创建Front API启动脚本
cat > /opt/zhibo/scripts/start-front-api.sh << 'EOF'
#!/bin/bash
APP_NAME="Crmeb-front"
JAR_FILE="/opt/zhibo/front-api/${APP_NAME}.jar"
LOG_FILE="/opt/zhibo/logs/front-api.log"
# 停止旧进程
pid=$(pgrep -f "${APP_NAME}.jar")
[ -n "$pid" ] && kill -9 $pid && echo "停止旧进程: $pid" && sleep 2
# 启动
nohup java -Xms512m -Xmx1024m -jar $JAR_FILE \
--spring.redis.host=127.0.0.1 \
> $LOG_FILE 2>&1 &
echo "Front API 启动成功,端口: 8081"
EOF
# 创建一键启动脚本
cat > /opt/zhibo/scripts/start-all.sh << 'EOF'
#!/bin/bash
echo "启动所有服务..."
/opt/zhibo/scripts/start-admin-api.sh
sleep 5
/opt/zhibo/scripts/start-front-api.sh
echo "完成!"
EOF
# 创建停止脚本
cat > /opt/zhibo/scripts/stop-all.sh << 'EOF'
#!/bin/bash
pkill -f "Crmeb-admin.jar" && echo "Admin API 已停止"
pkill -f "Crmeb-front.jar" && echo "Front API 已停止"
EOF
# 赋予执行权限
chmod +x /opt/zhibo/scripts/*.sh
```
### 2.2 配置Nginx
```bash
# 创建Nginx配置
cat > /etc/nginx/conf.d/zhibo.conf << 'EOF'
# 管理后台
server {
listen 80;
server_name _;
root /opt/zhibo/admin-web;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Admin API代理
location /api/admin/ {
proxy_pass http://127.0.0.1:30001/api/admin/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api/public/ {
proxy_pass http://127.0.0.1:30001/api/public/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
# Front API (供APP调用)
server {
listen 8080;
server_name _;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
}
EOF
# 测试并重载Nginx
nginx -t && systemctl reload nginx
```
---
## 第三步:启动服务
```bash
# 启动所有服务
/opt/zhibo/scripts/start-all.sh
# 查看日志
tail -f /opt/zhibo/logs/admin-api.log
tail -f /opt/zhibo/logs/front-api.log
# 检查端口
netstat -tlnp | grep -E "30001|8081"
```
---
## 第四步:验证部署
### 检查服务状态
```bash
# 检查进程
ps aux | grep -E "Crmeb-admin|Crmeb-front"
# 检查端口
ss -tlnp | grep -E "30001|8081|80|8080"
```
### 测试接口
```bash
# 测试Admin API
curl http://localhost:30001/api/public/version
# 测试Front API
curl http://localhost:8081/api/front/index
```
---
## 访问地址
| 服务 | 地址 |
|------|------|
| 管理后台 | http://1.15.149.240 |
| Admin API | http://1.15.149.240:30001 |
| Front API | http://1.15.149.240:8081 |
| Front API (Nginx) | http://1.15.149.240:8080 |
---
## 常用命令
```bash
# 启动所有服务
/opt/zhibo/scripts/start-all.sh
# 停止所有服务
/opt/zhibo/scripts/stop-all.sh
# 单独启动Admin API
/opt/zhibo/scripts/start-admin-api.sh
# 单独启动Front API
/opt/zhibo/scripts/start-front-api.sh
# 查看日志
tail -100f /opt/zhibo/logs/admin-api.log
tail -100f /opt/zhibo/logs/front-api.log
```
---
## APP配置
部署完成后需要修改Android APP的API地址
文件:`android-app/app/src/main/java/com/example/livestreaming/net/ApiConfig.java`
```java
public static final String BASE_URL = "http://1.15.149.240:8081/";
```
然后重新编译APK。
---
## 故障排查
### 服务启动失败
```bash
# 查看详细日志
cat /opt/zhibo/logs/admin-api.log
cat /opt/zhibo/logs/front-api.log
# 检查Java版本
java -version # 需要JDK 1.8
# 检查Redis是否运行
redis-cli ping
```
### 端口被占用
```bash
# 查看端口占用
lsof -i :30001
lsof -i :8081
# 杀掉占用进程
kill -9 <PID>
```