直播功能正常使用

This commit is contained in:
xiao12feng@outlook.com 2025-12-17 08:47:15 +08:00
parent 44d4ded7b2
commit 40bfd4ec5c
46 changed files with 3505 additions and 65 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

344
OBS推流问题诊断.md Normal file
View File

@ -0,0 +1,344 @@
# OBS 推流问题诊断与解决
## 🔍 问题现象
- ✅ OBS 显示"直播中"
- ❌ Android 模拟器显示"未开播"
- ❌ 视频无法播放
## 🎯 根本原因
**你在 OBS 中使用了错误的推流地址!**
### ❌ 错误做法
你可能直接复制了 Android 应用显示的地址:
```
rtmp://10.0.2.2:1935/live/a9c62788-135e-44cb-bbd1-6dbac2f5bf14
```
**问题**`10.0.2.2` 是 Android 模拟器专用的地址OBS 无法识别!
### ✅ 正确做法
OBS 应该使用:
```
rtmp://localhost:1935/live/a9c62788-135e-44cb-bbd1-6dbac2f5bf14
```
---
## 🔧 立即修复3 步)
### 步骤 1停止 OBS 推流
1. 在 OBS 中点击 **"停止推流"** 按钮
2. 等待状态栏显示停止
### 步骤 2修改推流地址
#### 方法 A完整地址方式
1. 点击 **"设置"** → **"推流"**
2. **服务器** 改为:
```
rtmp://localhost:1935/live/a9c62788-135e-44cb-bbd1-6dbac2f5bf14
```
**注意**:把 `10.0.2.2` 改成 `localhost`
3. **串流密钥**:留空
4. 点击 **"确定"**
#### 方法 B分开填写方式
1. 点击 **"设置"** → **"推流"**
2. **服务器**
```
rtmp://localhost:1935/live
```
3. **串流密钥**
```
a9c62788-135e-44cb-bbd1-6dbac2f5bf14
```
4. 点击 **"确定"**
### 步骤 3重新开始推流
1. 点击 **"开始推流"** 按钮
2. 等待 2-3 秒
3. 检查状态栏是否显示"直播中"
---
## ✅ 验证推流成功
### 方法 1查看 OBS 状态栏
正常推流应该显示:
```
直播中 | 00:00:15 | 2500 kb/s | 0 丢帧
```
**成功标志**
- ✅ 显示"直播中"
- ✅ 有比特率数据(如 2500 kb/s
- ✅ 丢帧数为 0 或很少
**失败标志**
- ❌ 提示"连接失败"
- ❌ 状态栏显示红色错误
- ❌ 比特率为 0
### 方法 2检查 SRS 服务器
打开浏览器访问:
```
http://localhost:1985/api/v1/streams/
```
**成功时应该看到**
```json
{
"code": 0,
"streams": [
{
"name": "a9c62788-135e-44cb-bbd1-6dbac2f5bf14",
"app": "live",
"publish": {
"active": true
}
}
]
}
```
**失败时会看到**
```json
{
"code": 0,
"streams": []
}
```
### 方法 3检查后端 API
打开浏览器访问:
```
http://localhost:3001/api/rooms
```
找到你的直播间,检查 `isLive` 字段:
```json
{
"id": "a9c62788-135e-44cb-bbd1-6dbac2f5bf14",
"isLive": true, ← 应该是 true
"streamUrls": {
"rtmp": "rtmp://localhost:1935/live/a9c62788-135e-44cb-bbd1-6dbac2f5bf14"
}
}
```
### 方法 4在 Android 应用中查看
1. 回到 Android 模拟器
2. 下拉刷新直播间列表
3. 直播间状态应该显示 **"直播中"**(红色标签)
4. 点击进入应该能看到视频
---
## 📋 完整的正确配置示例
### 你的直播间信息
```
直播间 ID: a9c62788-135e-44cb-bbd1-6dbac2f5bf14
Stream Key: a9c62788-135e-44cb-bbd1-6dbac2f5bf14
```
### OBS 推流设置(完整地址方式)
```
服务: 自定义...
服务器: rtmp://localhost:1935/live/a9c62788-135e-44cb-bbd1-6dbac2f5bf14
串流密钥: (留空)
```
### OBS 推流设置(分开填写方式)
```
服务: 自定义...
服务器: rtmp://localhost:1935/live
串流密钥: a9c62788-135e-44cb-bbd1-6dbac2f5bf14
```
### Android 应用中显示的地址
```
推流地址: rtmp://10.0.2.2:1935/live/a9c62788-135e-44cb-bbd1-6dbac2f5bf14
电脑本机 OBS 可用: rtmp://localhost:1935/live/a9c62788-135e-44cb-bbd1-6dbac2f5bf14
```
**记住**OBS 使用第二个地址localhost
---
## 🎬 完整操作流程(重新来一遍)
### 1. 确保后端服务器运行
```bash
cd live-streaming
node server/index.js
```
### 2. 在 Android 应用中创建直播间
- 点击"开始直播"
- 填写标题和主播名称
- 点击"创建"
- **记下 streamKey**(或点击"复制地址"
### 3. 配置 OBS
- 打开 OBS Studio
- 点击"设置" → "推流"
- 服务:自定义...
- 服务器:`rtmp://localhost:1935/live/a9c62788-135e-44cb-bbd1-6dbac2f5bf14`
- **重要**:使用 `localhost`,不是 `10.0.2.2`
- 点击"确定"
### 4. 添加视频源(如果还没有)
- 点击"来源"区域的 + 号
- 选择"视频捕获设备"(摄像头)或"显示器捕获"(屏幕)
- 配置并确定
### 5. 开始推流
- 点击"开始推流"
- 等待 2-3 秒
- 检查状态栏显示"直播中"
### 6. 在 Android 应用中观看
- 回到 Android 模拟器
- 下拉刷新或重新打开应用
- 直播间应该显示"直播中"
- 点击进入观看
---
## 🐛 常见错误及解决
### 错误 1OBS 提示"连接失败"
**原因**:推流地址错误
**解决**
1. 检查地址是否使用 `localhost` 而不是 `10.0.2.2`
2. 检查 streamKey 是否正确
3. 检查 SRS Docker 容器是否运行:`docker ps`
### 错误 2OBS 显示"直播中"但 Android 显示"未开播"
**原因**:推流到了错误的地址或 streamKey 不匹配
**解决**
1. 访问 http://localhost:1985/api/v1/streams/ 查看实际推流的 streamKey
2. 对比 Android 应用中的 streamKey 是否一致
3. 如果不一致,修改 OBS 的推流地址
### 错误 3Android 应用显示"直播中"但视频黑屏
**原因**:视频格式或网络问题
**解决**
1. 等待 5-10 秒HLS 需要缓冲)
2. 退出直播间重新进入
3. 检查后端日志是否有错误
4. 确认 HLS 地址可访问http://localhost:8080/live/a9c62788-135e-44cb-bbd1-6dbac2f5bf14.m3u8
### 错误 4推流一段时间后断开
**原因**:网络不稳定或比特率过高
**解决**
1. 降低 OBS 的视频比特率(设置 → 输出 → 2500 → 1500 Kbps
2. 降低分辨率(设置 → 视频 → 1080p → 720p
3. 检查网络连接
---
## 📸 OBS 设置截图说明
### 推流设置界面
```
┌─────────────────────────────────────┐
│ 设置 │
├─────────────────────────────────────┤
│ 推流 │
│ │
│ 服务: [自定义... ▼] │
│ │
│ 服务器: │
│ ┌─────────────────────────────────┐ │
│ │rtmp://localhost:1935/live/a9c62│ │ ← 填这里
│ │788-135e-44cb-bbd1-6dbac2f5bf14 │ │
│ └─────────────────────────────────┘ │
│ │
│ 串流密钥: │
│ ┌─────────────────────────────────┐ │
│ │ │ │ ← 留空
│ └─────────────────────────────────┘ │
│ │
│ [取消] [确定] │
└─────────────────────────────────────┘
```
---
## 🔍 调试命令
### 检查 SRS 推流状态
```bash
curl http://localhost:1985/api/v1/streams/
```
### 检查后端直播间状态
```bash
curl http://localhost:3001/api/rooms
```
### 检查特定直播间
```bash
curl http://localhost:3001/api/rooms/a9c62788-135e-44cb-bbd1-6dbac2f5bf14
```
### 检查 Docker 容器
```bash
docker ps
```
### 检查端口占用
```bash
netstat -ano | findstr :1935
netstat -ano | findstr :3001
```
---
## 💡 关键要点总结
### ✅ 正确的做法
1. **Android 应用**:用于创建直播间和观看
2. **OBS 推流地址**:使用 `localhost`,不是 `10.0.2.2`
3. **streamKey**:必须与直播间的 streamKey 完全一致
4. **验证推流**:访问 http://localhost:1985/api/v1/streams/ 确认
### ❌ 常见错误
1. 直接复制 Android 应用显示的 `10.0.2.2` 地址给 OBS
2. streamKey 输入错误或不完整
3. 忘记把 `10.0.2.2` 改成 `localhost`
4. SRS 服务器未启动
### 🎯 记住这个公式
```
Android 应用显示: rtmp://10.0.2.2:1935/live/{streamKey}
↓ 改成 ↓
OBS 实际使用: rtmp://localhost:1935/live/{streamKey}
```
---
## 📞 需要帮助?
如果按照以上步骤操作后仍然有问题:
1. 检查后端服务器日志(命令行窗口)
2. 检查 OBS 日志(帮助 → 日志文件)
3. 访问 http://localhost:1985/api/v1/streams/ 查看实际推流状态
4. 截图 OBS 的推流设置和错误信息
---
**现在就去修改 OBS 的推流地址吧!** 🚀

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

View File

@ -1,2 +1,2 @@
#Mon Dec 15 18:02:26 CST 2025
gradle.version=8.1
#Tue Dec 16 16:06:46 CST 2025
gradle.version=8.14

View File

@ -32,7 +32,7 @@ import retrofit2.Response;
public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
private RoomsAdapter adapter;
private WaterfallRoomsAdapter waterfallAdapter;
private final Handler handler = new Handler(Looper.getMainLooper());
private Runnable pollRunnable;
@ -43,15 +43,19 @@ public class MainActivity extends AppCompatActivity {
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
adapter = new RoomsAdapter(room -> {
// 使用瀑布流 Adapter
waterfallAdapter = new WaterfallRoomsAdapter(room -> {
if (room == null) return;
Intent intent = new Intent(MainActivity.this, RoomDetailActivity.class);
intent.putExtra(RoomDetailActivity.EXTRA_ROOM_ID, room.getId());
startActivity(intent);
});
binding.roomsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.roomsRecyclerView.setAdapter(adapter);
// 设置瀑布流布局管理器2列
androidx.recyclerview.widget.StaggeredGridLayoutManager layoutManager =
new androidx.recyclerview.widget.StaggeredGridLayoutManager(2, androidx.recyclerview.widget.StaggeredGridLayoutManager.VERTICAL);
binding.roomsRecyclerView.setLayoutManager(layoutManager);
binding.roomsRecyclerView.setAdapter(waterfallAdapter);
binding.startLiveButton.setOnClickListener(v -> showCreateRoomDialog());
}
@ -167,15 +171,21 @@ public class MainActivity extends AppCompatActivity {
String msg = "";
if (!TextUtils.isEmpty(rtmp)) {
msg += "推流地址(服务器)\n" + rtmp + "\n\n";
msg += "推流地址\n" + rtmp + "\n\n";
}
if (!TextUtils.isEmpty(rtmpForObs)) {
msg += "电脑本机 OBS 可用(等价地址)\n" + rtmpForObs + "\n\n";
msg += "电脑本机 OBS 可用\n" + rtmpForObs + "\n\n";
}
msg += "📺 OBS 推流设置:\n";
msg += "服务器rtmp://localhost:1935/live\n";
if (!TextUtils.isEmpty(streamKey)) {
msg += "推流密钥(Stream Key)\n" + streamKey + "\n\n";
msg += "串流密钥:" + streamKey + "\n\n";
}
msg += "提示:用 OBS 推流时,服务器填上面的推流地址,密钥填 streamKey。";
msg += "⚠️ 注意:\n";
msg += "1. 不要直接粘贴完整地址\n";
msg += "2. 要分开填写服务器和密钥\n";
msg += "3. Android 应用只能观看直播";
String copyRtmp = rtmp;
String copyKey = streamKey;
@ -184,7 +194,7 @@ public class MainActivity extends AppCompatActivity {
.setTitle("已创建直播间")
.setMessage(msg)
.setNegativeButton("复制地址", (d, w) -> copyToClipboard("rtmp", copyRtmp))
.setPositiveButton("复制密钥", (d, w) -> copyToClipboard("streamKey", copyKey))
.setPositiveButton("知道了", null)
.show();
}
@ -208,13 +218,13 @@ public class MainActivity extends AppCompatActivity {
binding.loading.setVisibility(View.GONE);
ApiResponse<List<Room>> body = response.body();
List<Room> rooms = body != null && body.getData() != null ? body.getData() : Collections.emptyList();
adapter.submitList(rooms);
waterfallAdapter.submitList(rooms);
}
@Override
public void onFailure(Call<ApiResponse<List<Room>>> call, Throwable t) {
binding.loading.setVisibility(View.GONE);
adapter.submitList(Collections.emptyList());
waterfallAdapter.submitList(Collections.emptyList());
}
});
}

View File

@ -0,0 +1,118 @@
package com.example.livestreaming;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout;
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.example.livestreaming.net.Room;
public class WaterfallRoomsAdapter extends ListAdapter<Room, WaterfallRoomsAdapter.RoomVH> {
public interface OnRoomClickListener {
void onRoomClick(Room room);
}
private final OnRoomClickListener onRoomClick;
public WaterfallRoomsAdapter(OnRoomClickListener onRoomClick) {
super(DIFF);
this.onRoomClick = onRoomClick;
}
@NonNull
@Override
public RoomVH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_room_waterfall, parent, false);
return new RoomVH(view, onRoomClick);
}
@Override
public void onBindViewHolder(@NonNull RoomVH holder, int position) {
holder.bind(getItem(position));
}
static class RoomVH extends RecyclerView.ViewHolder {
private final ImageView coverImage;
private final TextView liveBadge;
private final LinearLayout viewerCountLayout;
private final TextView viewerCount;
private final TextView roomTitle;
private final ImageView streamerAvatar;
private final TextView streamerName;
private final TextView hotBadge;
private final OnRoomClickListener onRoomClick;
RoomVH(View itemView, OnRoomClickListener onRoomClick) {
super(itemView);
this.onRoomClick = onRoomClick;
coverImage = itemView.findViewById(R.id.coverImage);
liveBadge = itemView.findViewById(R.id.liveBadge);
viewerCountLayout = itemView.findViewById(R.id.viewerCountLayout);
viewerCount = itemView.findViewById(R.id.viewerCount);
roomTitle = itemView.findViewById(R.id.roomTitle);
streamerAvatar = itemView.findViewById(R.id.streamerAvatar);
streamerName = itemView.findViewById(R.id.streamerName);
hotBadge = itemView.findViewById(R.id.hotBadge);
}
void bind(Room room) {
if (room == null) return;
// 设置标题
roomTitle.setText(room.getTitle() != null ? room.getTitle() : "(无标题)");
// 设置主播名称
streamerName.setText(room.getStreamerName() != null ? room.getStreamerName() : "");
// 设置直播状态
if (room.isLive()) {
liveBadge.setVisibility(View.VISIBLE);
viewerCountLayout.setVisibility(View.VISIBLE);
viewerCount.setText(String.valueOf(room.getViewerCount()));
// 如果观看人数超过100显示热门标签
if (room.getViewerCount() > 100) {
hotBadge.setVisibility(View.VISIBLE);
} else {
hotBadge.setVisibility(View.GONE);
}
} else {
liveBadge.setVisibility(View.GONE);
viewerCountLayout.setVisibility(View.GONE);
hotBadge.setVisibility(View.GONE);
}
// 点击事件
itemView.setOnClickListener(v -> {
if (onRoomClick != null) {
onRoomClick.onRoomClick(room);
}
});
}
}
private static final DiffUtil.ItemCallback<Room> DIFF = new DiffUtil.ItemCallback<Room>() {
@Override
public boolean areItemsTheSame(@NonNull Room oldItem, @NonNull Room newItem) {
String o = oldItem.getId();
String n = newItem.getId();
return o != null && o.equals(n);
}
@Override
public boolean areContentsTheSame(@NonNull Room oldItem, @NonNull Room newItem) {
return oldItem.equals(newItem);
}
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,40 +2,192 @@
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent">
android:layout_height="match_parent"
android:background="#F5F5F5">
<com.google.android.material.button.MaterialButton
android:id="@+id/startLiveButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="开始直播"
<!-- 顶部栏 -->
<LinearLayout
android:id="@+id/topBar"
android:layout_width="0dp"
android:layout_height="56dp"
android:background="@android:color/white"
android:elevation="4dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/titleText"
<ImageView
android:id="@+id/menuIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="菜单"
android:src="@android:drawable/ic_menu_sort_by_size" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<TextView
android:id="@+id/tabFollow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
android:text="关注"
android:textColor="#999999"
android:textSize="16sp" />
<TextView
android:id="@+id/tabDiscover"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
android:text="发现"
android:textColor="@color/purple_500"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tabNearby"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
android:text="附近"
android:textColor="#999999"
android:textSize="16sp" />
</LinearLayout>
<ImageView
android:id="@+id/messageIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="12dp"
android:contentDescription="消息"
android:src="@android:drawable/ic_dialog_email" />
<ImageView
android:id="@+id/notificationIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="通知"
android:src="@android:drawable/ic_popup_reminder" />
</LinearLayout>
<!-- 搜索栏 -->
<LinearLayout
android:id="@+id/searchBar"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
android:background="@drawable/search_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/topBar">
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="搜索"
android:src="@android:drawable/ic_menu_search"
android:tint="#999999" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:text="搜索主播/房间/标签"
android:textColor="#999999"
android:textSize="14sp" />
<ImageView
android:id="@+id/voiceSearchIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="语音搜索"
android:src="@android:drawable/ic_btn_speak_now"
android:tint="#999999" />
</LinearLayout>
<!-- 分类标签 -->
<HorizontalScrollView
android:id="@+id/categoryScroll"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="Rooms"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/startLiveButton"
android:layout_marginTop="12dp"
android:scrollbars="none"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toBottomOf="@id/searchBar">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp">
<TextView
android:id="@+id/categoryRecommend"
style="@style/CategoryChip"
android:background="@drawable/category_selected"
android:text="推荐"
android:textColor="@android:color/white" />
<TextView
android:id="@+id/categoryBeauty"
style="@style/CategoryChip"
android:text="颜值" />
<TextView
android:id="@+id/categoryTalent"
style="@style/CategoryChip"
android:text="才艺" />
<TextView
android:id="@+id/categoryOutdoor"
style="@style/CategoryChip"
android:text="户外" />
<TextView
android:id="@+id/categoryEntertainment"
style="@style/CategoryChip"
android:text="娱乐" />
<TextView
android:id="@+id/categoryChat"
style="@style/CategoryChip"
android:text="聊天" />
</LinearLayout>
</HorizontalScrollView>
<!-- 瀑布流内容区域 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/roomsRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="12dp"
android:clipToPadding="false"
android:paddingBottom="16dp"
android:paddingHorizontal="8dp"
android:paddingBottom="80dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleText" />
app:layout_constraintTop_toBottomOf="@id/categoryScroll" />
<!-- 加载指示器 -->
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
@ -46,4 +198,125 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 底部导航栏 -->
<LinearLayout
android:id="@+id/bottomNav"
android:layout_width="0dp"
android:layout_height="64dp"
android:background="@android:color/white"
android:elevation="8dp"
android:gravity="center"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<LinearLayout
android:id="@+id/navHome"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="首页"
android:src="@android:drawable/ic_menu_view"
android:tint="@color/purple_500" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="首页"
android:textColor="@color/purple_500"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/navDiscover"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="发现"
android:src="@android:drawable/ic_menu_compass"
android:tint="#999999" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="发现"
android:textColor="#999999"
android:textSize="12sp" />
</LinearLayout>
<!-- 中间开播按钮 -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/startLiveButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:contentDescription="开播"
android:src="@android:drawable/ic_input_add"
app:backgroundTint="@color/purple_500"
app:tint="@android:color/white" />
<LinearLayout
android:id="@+id/navMessage"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="消息"
android:src="@android:drawable/ic_dialog_email"
android:tint="#999999" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="消息"
android:textColor="#999999"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/navProfile"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="我的"
android:src="@android:drawable/ic_menu_myplaces"
android:tint="#999999" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="我的"
android:textColor="#999999"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,322 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent"
android:background="#F5F5F5">
<!-- 顶部栏 -->
<LinearLayout
android:id="@+id/topBar"
android:layout_width="0dp"
android:layout_height="56dp"
android:background="@android:color/white"
android:elevation="4dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/menuIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="菜单"
android:src="@android:drawable/ic_menu_sort_by_size" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<TextView
android:id="@+id/tabFollow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
android:text="关注"
android:textColor="#999999"
android:textSize="16sp" />
<TextView
android:id="@+id/tabDiscover"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
android:text="发现"
android:textColor="@color/purple_500"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tabNearby"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
android:text="附近"
android:textColor="#999999"
android:textSize="16sp" />
</LinearLayout>
<ImageView
android:id="@+id/messageIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="12dp"
android:contentDescription="消息"
android:src="@android:drawable/ic_dialog_email" />
<ImageView
android:id="@+id/notificationIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="通知"
android:src="@android:drawable/ic_popup_reminder" />
</LinearLayout>
<!-- 搜索栏 -->
<LinearLayout
android:id="@+id/searchBar"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
android:background="@drawable/search_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/topBar">
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="搜索"
android:src="@android:drawable/ic_menu_search"
android:tint="#999999" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:text="搜索主播/房间/标签"
android:textColor="#999999"
android:textSize="14sp" />
<ImageView
android:id="@+id/voiceSearchIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="语音搜索"
android:src="@android:drawable/ic_btn_speak_now"
android:tint="#999999" />
</LinearLayout>
<!-- 分类标签 -->
<HorizontalScrollView
android:id="@+id/categoryScroll"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:scrollbars="none"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/searchBar">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp">
<TextView
android:id="@+id/categoryRecommend"
style="@style/CategoryChip"
android:background="@drawable/category_selected"
android:text="推荐"
android:textColor="@android:color/white" />
<TextView
android:id="@+id/categoryBeauty"
style="@style/CategoryChip"
android:text="颜值" />
<TextView
android:id="@+id/categoryTalent"
style="@style/CategoryChip"
android:text="才艺" />
<TextView
android:id="@+id/categoryOutdoor"
style="@style/CategoryChip"
android:text="户外" />
<TextView
android:id="@+id/categoryEntertainment"
style="@style/CategoryChip"
android:text="娱乐" />
<TextView
android:id="@+id/categoryChat"
style="@style/CategoryChip"
android:text="聊天" />
</LinearLayout>
</HorizontalScrollView>
<!-- 瀑布流内容区域 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/roomsRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="12dp"
android:clipToPadding="false"
android:paddingHorizontal="8dp"
android:paddingBottom="80dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/categoryScroll" />
<!-- 加载指示器 -->
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 底部导航栏 -->
<LinearLayout
android:id="@+id/bottomNav"
android:layout_width="0dp"
android:layout_height="64dp"
android:background="@android:color/white"
android:elevation="8dp"
android:gravity="center"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<LinearLayout
android:id="@+id/navHome"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="首页"
android:src="@android:drawable/ic_menu_view"
android:tint="@color/purple_500" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="首页"
android:textColor="@color/purple_500"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/navDiscover"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="发现"
android:src="@android:drawable/ic_menu_compass"
android:tint="#999999" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="发现"
android:textColor="#999999"
android:textSize="12sp" />
</LinearLayout>
<!-- 中间开播按钮 -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/startLiveButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:contentDescription="开播"
android:src="@android:drawable/ic_input_add"
app:backgroundTint="@color/purple_500"
app:tint="@android:color/white" />
<LinearLayout
android:id="@+id/navMessage"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="消息"
android:src="@android:drawable/ic_dialog_email"
android:tint="#999999" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="消息"
android:textColor="#999999"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/navProfile"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="我的"
android:src="@android:drawable/ic_menu_myplaces"
android:tint="#999999" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="我的"
android:textColor="#999999"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="match_parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/startLiveButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="开始直播"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/titleText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="Rooms"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/startLiveButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/roomsRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleText" />
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -3,49 +3,143 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
app:cardCornerRadius="12dp">
android:layout_margin="8dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
android:layout_height="wrap_content">
<TextView
android:id="@+id/roomTitle"
<!-- 封面图 -->
<ImageView
android:id="@+id/coverImage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Room Title"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/liveBadge"
android:layout_height="0dp"
android:contentDescription="封面"
android:scaleType="centerCrop"
android:src="@android:drawable/ic_menu_gallery"
app:layout_constraintDimensionRatio="3:4"
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:paddingHorizontal="10dp"
android:layout_margin="8dp"
android:background="@drawable/live_badge_background"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:text="LIVE"
android:text="直播中"
android:textColor="@android:color/white"
android:textSize="12sp"
android:textSize="10sp"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/streamerName"
<!-- 观看人数 -->
<LinearLayout
android:id="@+id/viewerCountLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:background="@drawable/viewer_count_background"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:layout_width="12dp"
android:layout_height="12dp"
android:contentDescription="观看"
android:src="@android:drawable/ic_menu_view"
android:tint="@android:color/white" />
<TextView
android:id="@+id/viewerCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="0"
android:textColor="@android:color/white"
android:textSize="10sp" />
</LinearLayout>
<!-- 底部信息区域 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Streamer"
android:textColor="#666666"
android:orientation="vertical"
android:padding="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomTitle" />
app:layout_constraintTop_toBottomOf="@id/coverImage">
<!-- 标题 -->
<TextView
android:id="@+id/roomTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:text="直播间标题"
android:textColor="#333333"
android:textSize="14sp"
android:textStyle="bold" />
<!-- 主播信息 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<!-- 主播头像 -->
<ImageView
android:id="@+id/streamerAvatar"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="主播头像"
android:src="@android:drawable/ic_menu_myplaces"
android:tint="@color/purple_500" />
<!-- 主播名称 -->
<TextView
android:id="@+id/streamerName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="主播名称"
android:textColor="#666666"
android:textSize="12sp" />
<!-- 热度标签 -->
<TextView
android:id="@+id/hotBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/hot_badge_background"
android:paddingHorizontal="6dp"
android:paddingVertical="2dp"
android:text="热"
android:textColor="@color/live_red"
android:textSize="10sp"
android:textStyle="bold"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<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_marginHorizontal="16dp"
android:layout_marginTop="12dp"
app:cardCornerRadius="12dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/roomTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Room Title"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/liveBadge"
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:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:text="LIVE"
android:textColor="@android:color/white"
android:textSize="12sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/streamerName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Streamer"
android:textColor="#666666"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomTitle" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<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="8dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- 封面图 -->
<ImageView
android:id="@+id/coverImage"
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="封面"
android:scaleType="centerCrop"
android:src="@android:drawable/ic_menu_gallery"
app:layout_constraintDimensionRatio="3:4"
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_margin="8dp"
android:background="@drawable/live_badge_background"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:text="直播中"
android:textColor="@android:color/white"
android:textSize="10sp"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 观看人数 -->
<LinearLayout
android:id="@+id/viewerCountLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:background="@drawable/viewer_count_background"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:layout_width="12dp"
android:layout_height="12dp"
android:contentDescription="观看"
android:src="@android:drawable/ic_menu_view"
android:tint="@android:color/white" />
<TextView
android:id="@+id/viewerCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="0"
android:textColor="@android:color/white"
android:textSize="10sp" />
</LinearLayout>
<!-- 底部信息区域 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp"
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_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:text="直播间标题"
android:textColor="#333333"
android:textSize="14sp"
android:textStyle="bold" />
<!-- 主播信息 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<!-- 主播头像 -->
<ImageView
android:id="@+id/streamerAvatar"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="主播头像"
android:src="@android:drawable/ic_menu_myplaces"
android:tint="@color/purple_500" />
<!-- 主播名称 -->
<TextView
android:id="@+id/streamerName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="主播名称"
android:textColor="#666666"
android:textSize="12sp" />
<!-- 热度标签 -->
<TextView
android:id="@+id/hotBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/hot_badge_background"
android:paddingHorizontal="6dp"
android:paddingVertical="2dp"
android:text="热"
android:textColor="@color/live_red"
android:textSize="10sp"
android:textStyle="bold"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -7,9 +7,23 @@
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@android:color/black</item>
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
<item name="android:statusBarColor">@android:color/white</item>
<item name="android:navigationBarColor">@android:color/white</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
</style>
<!-- 分类标签样式 -->
<style name="CategoryChip">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:paddingStart">16dp</item>
<item name="android:paddingEnd">16dp</item>
<item name="android:paddingTop">8dp</item>
<item name="android:paddingBottom">8dp</item>
<item name="android:layout_marginEnd">8dp</item>
<item name="android:background">@drawable/category_normal</item>
<item name="android:textColor">#666666</item>
<item name="android:textSize">14sp</item>
</style>
</resources>

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=file:///D:/soft/gradle-8.1-bin.zip
distributionUrl=file:///D:/soft/gradle-8.14-bin.zip
networkTimeout=600000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -0,0 +1,235 @@
# Android 直播应用新 UI 设计说明
## 🎨 设计概览
已将 Android 应用首页重新设计为现代化的瀑布流布局,参考了主流直播应用的设计风格。
---
## ✅ 已完成的修改
### 1. 新增布局文件
#### 主界面布局 (`activity_main.xml`)
- **顶部导航栏**
- 左侧:菜单图标
- 中间:关注/发现/附近 标签页
- 右侧:消息和通知图标
- **搜索栏**
- 搜索框 + 语音搜索按钮
- 圆角设计,灰色背景
- **分类标签**
- 横向滚动
- 推荐、颜值、才艺、户外、娱乐、聊天
- 选中状态:紫色背景 + 白色文字
- 未选中:灰色背景 + 灰色文字
- **内容区域**
- 瀑布流布局2列
- RecyclerView + StaggeredGridLayoutManager
- **底部导航栏**
- 首页、发现、开播(中间大按钮)、消息、我的
- 固定在底部,白色背景,带阴影
#### 直播间卡片布局 (`item_room_waterfall.xml`)
- **封面图**3:4 比例
- **直播标签**:左上角红色标签(直播中时显示)
- **观看人数**:右上角半透明黑色背景
- **底部信息**
- 标题最多2行
- 主播头像 + 名称
- 热门标签(观看人数>100时显示
### 2. 新增 Drawable 资源
| 文件名 | 用途 |
|--------|------|
| `search_background.xml` | 搜索框背景(圆角灰色) |
| `category_selected.xml` | 选中的分类标签背景(紫色) |
| `category_normal.xml` | 未选中的分类标签背景(灰色) |
| `live_badge_background.xml` | 直播标签背景(红色) |
| `viewer_count_background.xml` | 观看人数背景(半透明黑色) |
| `hot_badge_background.xml` | 热门标签背景(红色边框) |
### 3. 新增 Java 类
#### `WaterfallRoomsAdapter.java`
- 瀑布流适配器
- 支持直播状态显示
- 支持观看人数显示
- 支持热门标签(观看人数>100
### 4. 修改的文件
#### `MainActivity.java`
- 使用 `WaterfallRoomsAdapter` 替代 `RoomsAdapter`
- 使用 `StaggeredGridLayoutManager` 实现瀑布流2列
- 保持原有的轮询和点击逻辑
#### `themes.xml`
- 添加 `CategoryChip` 样式
- 修改状态栏和导航栏颜色为白色
- 启用浅色状态栏图标
---
## 🎨 设计细节
### 颜色方案
- **主色调**:紫色 (`#6200EE`)
- **强调色**:红色 (`#E53935`) - 用于直播标签
- **背景色**:浅灰 (`#F5F5F5`)
- **文字色**
- 主要文字:`#333333`
- 次要文字:`#666666`
- 提示文字:`#999999`
### 圆角设计
- 搜索框24dp
- 分类标签16dp
- 直播间卡片12dp
- 直播标签4dp
### 间距规范
- 页面边距16dp
- 卡片间距8dp
- 元素内边距12dp
---
## 📱 功能说明
### 当前已实现
- ✅ 瀑布流布局展示直播间
- ✅ 直播状态标签
- ✅ 观看人数显示
- ✅ 点击进入直播间
- ✅ 自动刷新列表5秒轮询
- ✅ 开播按钮(底部导航中间)
### 待实现UI已准备好
- ⏳ 搜索功能
- ⏳ 语音搜索
- ⏳ 分类筛选
- ⏳ 关注/发现/附近 标签页切换
- ⏳ 消息和通知功能
- ⏳ 底部导航其他页面
---
## 🔧 如何编译和运行
### 1. 清理项目
```bash
cd android-app
gradlew clean
```
### 2. 编译
```bash
gradlew assembleDebug
```
### 3. 安装到模拟器
```bash
gradlew installDebug
```
或者在 Android Studio 中直接点击 ▶️ Run
---
## 📸 布局预览
### 主界面结构
```
┌─────────────────────────────┐
│ ☰ 关注 发现 附近 ✉ 🔔 │ ← 顶部导航
├─────────────────────────────┤
│ 🔍 搜索主播/房间/标签 🎤 │ ← 搜索栏
├─────────────────────────────┤
│ [推荐][颜值][才艺][户外]... │ ← 分类标签
├─────────────────────────────┤
│ ┌──────┐ ┌──────┐ │
│ │ 封面 │ │ 封面 │ │
│ │ 图片 │ │ 图片 │ │
│ │ │ │ │ │ ← 瀑布流内容
│ │ 标题 │ │ 标题 │ │
│ │ 主播 │ │ 主播 │ │
│ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ │
│ │ ... │ │ ... │ │
└─────────────────────────────┘
│ 🏠 🔍 💬 👤 │ ← 底部导航
└─────────────────────────────┘
```
### 直播间卡片结构
```
┌─────────────────┐
│ [直播中] 👁 123│ ← 标签和观看人数
│ │
│ 封面图片 │
│ │
│ │
├─────────────────┤
│ 直播间标题 │
│ 👤 主播名称 [热] │ ← 主播信息
└─────────────────┘
```
---
## 🐛 已知问题
1. **封面图片**:当前使用默认图标,需要后端提供封面图 URL
2. **主播头像**:当前使用默认图标,需要后端提供头像 URL
3. **分类筛选**UI已完成但功能未实现
4. **搜索功能**UI已完成但功能未实现
---
## 🔄 如何恢复旧版 UI
如果需要恢复旧版 UI
```bash
cd android-app/app/src/main/res/layout
Copy-Item activity_main_old.xml activity_main.xml -Force
Copy-Item item_room_old.xml item_room.xml -Force
```
然后在 `MainActivity.java` 中:
- 将 `WaterfallRoomsAdapter` 改回 `RoomsAdapter`
- 将 `StaggeredGridLayoutManager` 改回 `LinearLayoutManager`
---
## 📝 后续优化建议
### 1. 添加封面图支持
- 后端返回封面图 URL
- 使用 Glide 或 Picasso 加载图片
### 2. 实现分类筛选
- 点击分类标签时筛选对应类型的直播间
- 添加分类字段到 Room 模型
### 3. 实现搜索功能
- 点击搜索框打开搜索页面
- 支持搜索主播名称和直播间标题
### 4. 添加下拉刷新
- 使用 SwipeRefreshLayout
- 手动刷新直播间列表
### 5. 添加加载更多
- 监听滚动到底部
- 分页加载直播间数据
---
**设计完成!现在可以编译运行查看新的 UI 效果了!** 🎉

View File

@ -0,0 +1,132 @@
# Android 直播应用问题修复说明
## 问题诊断
### 1. RTMP 地址格式错误 ❌
**问题**:后端返回的 RTMP 地址不完整
- 错误格式:`rtmp://10.0.2.2:1935/live`
- 正确格式:`rtmp://10.0.2.2:1935/live/{streamKey}`
**原因**`live-streaming/server/utils/streamUrl.js` 中缺少 streamKey
### 2. Android 应用功能误解 ⚠️
**重要**:你的 Android 应用**不支持推流**
当前应用功能:
- ✅ 创建直播间
- ✅ 查看直播列表
- ✅ 观看直播(播放 HLS/FLV 流)
- ❌ **不能**从 Android 设备推流
正确的使用流程:
1. 在 Android 应用中创建直播间
2. 获取 RTMP 推流地址
3. **在电脑上用 OBS Studio 推流**
4. 在 Android 应用中观看直播
## 已完成的修复 ✅
### 1. 修复后端 RTMP 地址
修改了 `live-streaming/server/utils/streamUrl.js`
```javascript
// 修复前
rtmp: `rtmp://${host}:${rtmpPort}/live`,
// 修复后
rtmp: `rtmp://${host}:${rtmpPort}/live/${streamKey}`,
```
### 2. 更新 Android 应用提示
修改了 `MainActivity.java` 中的提示信息,明确说明需要用 OBS 推流。
## 如何使用
### 步骤 1启动后端服务器
```bash
cd live-streaming
npm start
```
确认服务器运行:
```bash
curl http://localhost:3001/health
```
### 步骤 2重新构建 Android 应用
```bash
cd android-app
gradlew clean assembleDebug
gradlew installDebug
```
或在 Android Studio 中点击 "Run"
### 步骤 3创建直播间
1. 在 Android 应用中点击"开始直播"
2. 填写直播间标题和主播名称
3. 创建成功后会显示 RTMP 推流地址
### 步骤 4使用 OBS 推流
1. 打开 OBS Studio
2. 设置 → 推流
3. 服务器:`rtmp://localhost:1935/live/{你的streamKey}`
- 或直接填写应用显示的完整地址
4. 开始推流
### 步骤 5在 Android 应用中观看
推流成功后,直播间会显示"直播中"状态,点击进入即可观看。
## Android 模拟器网络说明
在 Android 模拟器中:
- `10.0.2.2` = 宿主机的 `localhost`
- `10.0.2.16` = 模拟器自己的 IP
所以:
- Android 应用访问 API`http://10.0.2.2:3001/api/`
- OBS 推流地址:`rtmp://localhost:1935/live/{streamKey}`
- Android 观看地址:`http://10.0.2.2:8080/live/{streamKey}.flv`
## 常见问题
### Q: 为什么 Android 应用不能直接推流?
A: 从 Android 设备推流需要:
- 摄像头/麦克风权限
- RTMP 推流库(如 librtmp
- 视频编码处理
- 当前应用没有实现这些功能
### Q: 如果想让 Android 应用支持推流怎么办?
A: 需要添加推流功能,可以使用:
- [yasea](https://github.com/begeekmyfriend/yasea) - Android RTMP 推流库
- [LiveVideoBroadcaster](https://github.com/ant-media/LiveVideoBroadcaster) - Ant Media 的推流方案
### Q: 推流后 Android 应用看不到直播?
A: 检查:
1. OBS 是否成功推流(查看 OBS 状态栏)
2. 后端服务器是否收到推流(查看服务器日志)
3. RTMP 地址和 streamKey 是否正确
4. 防火墙是否阻止了端口 1935 和 8080
### Q: 出现"服务器异常"错误?
A: 可能原因:
1. 后端服务器未启动
2. 网络连接问题
3. API 地址配置错误
4. 查看 Android Studio 的 Logcat 获取详细错误信息
## 防火墙配置
如果遇到连接问题,添加防火墙规则:
```powershell
netsh advfirewall firewall add rule name="Node.js API" dir=in action=allow protocol=TCP localport=3001
netsh advfirewall firewall add rule name="RTMP Server" dir=in action=allow protocol=TCP localport=1935
netsh advfirewall firewall add rule name="HTTP-FLV" dir=in action=allow protocol=TCP localport=8080
```
## 相关文件
- 后端服务器:`live-streaming/server/index.js`
- RTMP 地址生成:`live-streaming/server/utils/streamUrl.js`
- Android 配置:`android-app/app/build.gradle.kts`
- Android 主界面:`android-app/app/src/main/java/com/example/livestreaming/MainActivity.java`
- Android 直播间:`android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java`

View File

@ -0,0 +1,160 @@
# Android 直播应用问题修复总结
## 问题现象
OBS 推流成功,但 Android 模拟器显示"主播尚未开播"(未开播状态)
## 根本原因
### 1. SRS API URL 格式错误 ❌
后端代码访问 SRS API 时缺少末尾斜杠:
- 错误:`http://localhost:1985/api/v1/streams?count=100`
- 正确:`http://localhost:1985/api/v1/streams/?count=100`
SRS 服务器对缺少斜杠的请求返回 302 重定向,导致后端无法获取推流状态。
### 2. 缺少 SRS_API_PORT 配置 ❌
`.env` 文件中没有配置 `SRS_API_PORT=1985`,虽然代码有默认值,但明确配置更好。
### 3. 房间数据未持久化 ❌
重启服务器后,内存中的房间数据丢失,导致推流无法关联到房间。
## 已完成的修复 ✅
### 1. 修复 SRS API URL
**文件**`live-streaming/server/utils/srsHttpApi.js`
```javascript
// 修复前
const url = `http://${host}:${apiPort}/api/v1/streams?count=100`;
// 修复后
const url = `http://${host}:${apiPort}/api/v1/streams/?count=100`;
```
### 2. 添加 SRS_API_PORT 配置
**文件**`live-streaming/.env`
```env
SRS_API_PORT=1985
```
### 3. 实现房间数据持久化
**文件**`live-streaming/server/store/roomStore.js`
- 添加了文件存储功能
- 房间数据保存在 `live-streaming/data/rooms.json`
- 服务器重启后自动加载房间数据
### 4. 修复 RTMP 地址格式
**文件**`live-streaming/server/utils/streamUrl.js`
```javascript
// 修复前
rtmp: `rtmp://${host}:${rtmpPort}/live`,
// 修复后
rtmp: `rtmp://${host}:${rtmpPort}/live/${streamKey}`,
```
### 5. 改进错误日志
添加了更详细的 SRS API 错误信息,便于调试。
## 验证结果 ✅
测试 API 返回:
```json
{
"isLive": true,
"streamKey": "868c49cc-1021-4664-95a3-ed71e789adb2",
"streamUrls": {
"rtmp": "rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2",
"flv": "http://localhost:8080/live/868c49cc-1021-4664-95a3-ed71e789adb2.flv",
"hls": "http://localhost:8080/live/868c49cc-1021-4664-95a3-ed71e789adb2.m3u8"
}
}
```
## 使用说明
### 1. 启动服务
```bash
cd live-streaming
node server/index.js
```
### 2. 在 Android 应用中
- 打开应用,查看房间列表
- 点击房间进入,应该显示"直播中"状态
- 视频播放器会自动加载 HLS 流
### 3. OBS 推流设置
- 服务器:`rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2`
- 或者分开填写:
- 服务器:`rtmp://localhost:1935/live`
- 串流密钥:`868c49cc-1021-4664-95a3-ed71e789adb2`
## 技术架构
```
┌─────────────┐
│ OBS 推流 │
└──────┬──────┘
│ RTMP (1935)
┌─────────────────┐
│ SRS 服务器 │
│ (Docker) │
│ - RTMP: 1935 │
│ - HTTP: 8080 │
│ - API: 1985 │
└────────┬────────┘
│ HTTP API (1985)
┌─────────────────┐
│ Node.js 后端 │
│ (端口 3001) │
└────────┬────────┘
│ REST API
┌─────────────────┐
│ Android 应用 │
│ (模拟器) │
└─────────────────┘
```
## 网络地址映射
| 位置 | localhost | 10.0.2.2 | 说明 |
|------|-----------|----------|------|
| 电脑 OBS | ✅ | ❌ | 使用 localhost |
| Android 模拟器 | ❌ | ✅ | 使用 10.0.2.2 |
| 后端服务器 | ✅ | ✅ | 监听 0.0.0.0 |
## 常见问题
### Q: 重启服务器后房间消失?
A: 已修复!现在房间数据会保存在 `live-streaming/data/rooms.json`
### Q: Android 应用显示"未开播"
A: 检查:
1. OBS 是否正在推流
2. 后端服务器是否运行
3. SRS Docker 容器是否运行:`docker ps`
4. 查看后端日志是否有 SRS API 错误
### Q: 视频播放黑屏?
A: 可能原因:
1. HLS 转码未启用(需要 FFmpeg
2. Android 播放器不支持 FLV
3. 网络延迟或缓冲
### Q: 如何查看 SRS 推流状态?
A: 访问 `http://localhost:1985/api/v1/streams/`
## 相关文件
- 后端服务器:`live-streaming/server/index.js`
- SRS API 工具:`live-streaming/server/utils/srsHttpApi.js`
- 流地址生成:`live-streaming/server/utils/streamUrl.js`
- 房间存储:`live-streaming/server/store/roomStore.js`
- 环境配置:`live-streaming/.env`
- SRS 配置:`live-streaming/docker/srs/srs.conf`
- Android 配置:`android-app/app/build.gradle.kts`

View File

@ -3,5 +3,6 @@ PORT=3001
SRS_HOST=localhost
SRS_RTMP_PORT=1935
SRS_HTTP_PORT=8080
SRS_API_PORT=1985
CLIENT_URL=http://localhost:3000
EMBEDDED_MEDIA_SERVER=0

View File

@ -0,0 +1,22 @@
[
{
"id": "7f4acb94-f91c-4ec0-84eb-fbb1855f8f18",
"title": "11",
"streamerName": "111",
"streamKey": "7f4acb94-f91c-4ec0-84eb-fbb1855f8f18",
"isLive": false,
"viewerCount": 0,
"createdAt": "2025-12-16T09:37:13.684Z",
"startedAt": null
},
{
"id": "a85b8d00-5f9c-46a9-b664-f3153812b516",
"title": "22",
"streamerName": "22",
"streamKey": "a85b8d00-5f9c-46a9-b664-f3153812b516",
"isLive": false,
"viewerCount": 0,
"createdAt": "2025-12-16T09:50:13.396Z",
"startedAt": null
}
]

View File

@ -35,6 +35,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -1405,6 +1406,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -2291,6 +2293,22 @@
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"ideallyInert": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",

View File

@ -52,6 +52,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -1422,6 +1423,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",

View File

@ -145,8 +145,9 @@ app.get('/health', (req, res) => {
app.use(errorHandler);
// 启动服务
app.listen(PORT, () => {
app.listen(PORT, '0.0.0.0', () => {
console.log(`API 服务运行在 http://localhost:${PORT}`);
console.log(`API 服务也可通过 http://0.0.0.0:${PORT} 访问(用于 Android 模拟器)`);
console.log(`SRS RTMP: rtmp://${process.env.SRS_HOST || 'localhost'}:${process.env.SRS_RTMP_PORT || 1935}/live/{streamKey}`);
console.log(`SRS HTTP: http://${process.env.SRS_HOST || 'localhost'}:${process.env.SRS_HTTP_PORT || 8080}/live/{streamKey}.flv`);
});

View File

@ -1,7 +1,47 @@
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const path = require('path');
// 内存存储
const rooms = new Map();
// 持久化文件路径
const STORAGE_FILE = path.join(__dirname, '../../data/rooms.json');
// 确保数据目录存在
const ensureDataDir = () => {
const dir = path.dirname(STORAGE_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
};
// 从文件加载房间数据
const loadRooms = () => {
try {
ensureDataDir();
if (fs.existsSync(STORAGE_FILE)) {
const data = fs.readFileSync(STORAGE_FILE, 'utf8');
const roomsArray = JSON.parse(data);
return new Map(roomsArray.map(room => [room.id, room]));
}
} catch (e) {
console.warn('[RoomStore] Failed to load rooms from file:', e.message);
}
return new Map();
};
// 保存房间数据到文件
const saveRooms = (rooms) => {
try {
ensureDataDir();
const roomsArray = Array.from(rooms.values());
fs.writeFileSync(STORAGE_FILE, JSON.stringify(roomsArray, null, 2), 'utf8');
} catch (e) {
console.error('[RoomStore] Failed to save rooms to file:', e.message);
}
};
// 内存存储(从文件加载)
const rooms = loadRooms();
console.log(`[RoomStore] Loaded ${rooms.size} rooms from storage`);
const roomStore = {
// 创建房间
@ -18,6 +58,7 @@ const roomStore = {
startedAt: null
};
rooms.set(id, room);
saveRooms(rooms);
return room;
},
@ -43,6 +84,7 @@ const roomStore = {
const updated = { ...room, ...data };
rooms.set(id, updated);
saveRooms(rooms);
return updated;
},
@ -54,12 +96,15 @@ const roomStore = {
room.isLive = isLive;
room.startedAt = isLive ? new Date().toISOString() : null;
rooms.set(streamKey, room);
saveRooms(rooms);
return room;
},
// 删除房间
delete(id) {
return rooms.delete(id);
const result = rooms.delete(id);
if (result) saveRooms(rooms);
return result;
},
// 清空所有房间 (测试用)

View File

@ -59,7 +59,7 @@ const getActiveStreamKeys = async ({ app = 'live' } = {}) => {
const host = process.env.SRS_HOST || 'localhost';
const apiPort = process.env.SRS_API_PORT || 1985;
const url = `http://${host}:${apiPort}/api/v1/streams?count=100`;
const url = `http://${host}:${apiPort}/api/v1/streams/?count=100`;
try {
const payload = await requestJson(url, { timeoutMs: 1500 });
@ -77,7 +77,9 @@ const getActiveStreamKeys = async ({ app = 'live' } = {}) => {
} catch (e) {
if (!warnedOnce) {
warnedOnce = true;
console.warn(`[SRS] HTTP API unavailable at ${url}. Will fallback to callbacks/in-memory status.`);
console.warn(`[SRS] HTTP API unavailable at ${url}`);
console.warn(`[SRS] Error: ${e.message}`);
console.warn(`[SRS] Will fallback to callbacks/in-memory status.`);
}
return new Set();
}

View File

@ -11,8 +11,8 @@ const generateStreamUrls = (streamKey, requestHost) => {
: !['0', 'false', 'off', 'no'].includes(String(embeddedEnabledRaw).toLowerCase());
return {
// 推流地址 (给主播用)
rtmp: `rtmp://${host}:${rtmpPort}/live`,
// 推流地址 (给主播用) - 完整路径包含 streamKey
rtmp: `rtmp://${host}:${rtmpPort}/live/${streamKey}`,
// 播放地址 (给观众用)
flv: `http://${host}:${httpPort}/live/${streamKey}.flv`,

594
使用教程.md Normal file
View File

@ -0,0 +1,594 @@
# Android 直播系统完整使用教程
## 目录
1. [系统架构说明](#系统架构说明)
2. [环境准备](#环境准备)
3. [启动后端服务](#启动后端服务)
4. [Android Studio 使用教程](#android-studio-使用教程)
5. [OBS Studio 推流教程](#obs-studio-推流教程)
6. [完整操作流程](#完整操作流程)
7. [常见问题解决](#常见问题解决)
---
## 系统架构说明
```
┌──────────────┐
│ 电脑 OBS │ ← 你在这里推流(摄像头/屏幕)
└──────┬───────┘
│ RTMP 推流
┌──────────────┐
│ SRS 服务器 │ ← 接收推流,转换格式
│ (Docker) │
└──────┬───────┘
┌──────────────┐
│ Node.js 后端 │ ← 管理直播间,提供 API
└──────┬───────┘
│ REST API
┌──────────────┐
│ Android 应用 │ ← 观看直播
│ (模拟器) │
└──────────────┘
```
**角色说明**
- **OBS**:主播端,用于推流(发送视频)
- **Android 应用**:观众端,用于观看直播
- **后端服务**:管理直播间,连接 OBS 和 Android
---
## 环境准备
### 1. 必需软件
#### ✅ 已安装
- [x] Node.js
- [x] Docker Desktop
- [x] Android Studio
- [x] Android 模拟器
#### 📥 需要下载
- [ ] **OBS Studio** - 推流软件
- 下载地址https://obsproject.com/download
- 选择 Windows 版本
- 安装后重启电脑
### 2. 检查 Docker 容器
打开命令行,运行:
```bash
docker ps
```
应该看到 `srs-server` 容器正在运行:
```
CONTAINER ID IMAGE PORTS NAMES
f24a21e0bc02 ossrs/srs:5 0.0.0.0:1935->1935/tcp srs-server
0.0.0.0:8080->8080/tcp
0.0.0.0:1985->1985/tcp
```
如果没有运行,启动它:
```bash
cd live-streaming
docker-compose up -d
```
---
## 启动后端服务
### 步骤 1打开命令行
在项目根目录打开 PowerShell 或 CMD
### 步骤 2进入后端目录
```bash
cd live-streaming
```
### 步骤 3启动服务器
```bash
node server/index.js
```
### 步骤 4验证服务器运行
你应该看到类似输出:
```
[RoomStore] Loaded 1 rooms from storage
[Media Server] Embedded NodeMediaServer disabled (EMBEDDED_MEDIA_SERVER=0).
API 服务运行在 http://localhost:3001
API 服务也可通过 http://0.0.0.0:3001 访问(用于 Android 模拟器)
SRS RTMP: rtmp://localhost:1935/live/{streamKey}
SRS HTTP: http://localhost:8080/live/{streamKey}.flv
```
**⚠️ 重要**:保持这个命令行窗口打开,不要关闭!
### 步骤 5测试服务器可选
打开浏览器访问:
```
http://localhost:3001/health
```
应该看到:
```json
{"status":"ok","timestamp":"2025-12-16T09:30:00.000Z"}
```
---
## Android Studio 使用教程
### 步骤 1打开项目
1. 启动 **Android Studio**
2. 点击 **Open**
3. 选择 `android-app` 文件夹
4. 点击 **OK**
### 步骤 2等待 Gradle 同步
- 首次打开会自动下载依赖
- 等待底部状态栏显示 "Gradle sync finished"
- 可能需要 5-10 分钟
### 步骤 3启动 Android 模拟器
#### 方法 A使用现有模拟器
1. 点击顶部工具栏的设备下拉菜单
2. 选择一个模拟器(如 Pixel 5 API 34
3. 点击绿色的 ▶️ **Run** 按钮
#### 方法 B创建新模拟器
1. 点击 **Tools** → **Device Manager**
2. 点击 **Create Device**
3. 选择 **Phone** → **Pixel 5**
4. 点击 **Next**
5. 选择系统镜像(推荐 **API 34**
6. 点击 **Next** → **Finish**
7. 点击 ▶️ 启动模拟器
### 步骤 4运行应用
1. 等待模拟器完全启动(显示主屏幕)
2. 在 Android Studio 中点击 ▶️ **Run 'app'**
3. 应用会自动安装并启动
### 步骤 5创建直播间
在 Android 应用中:
1. 点击右上角的 **"开始直播"** 按钮
2. 填写信息:
- **直播间标题**:例如 "我的第一次直播"
- **主播名称**:例如 "小明"
3. 点击 **"创建"**
### 步骤 6获取推流地址
创建成功后会显示一个对话框,包含:
```
推流地址:
rtmp://10.0.2.2:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2
电脑本机 OBS 可用:
rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2
⚠️ 注意:
1. Android 应用只能观看直播
2. 需要用 OBS 在电脑上推流
3. OBS 设置:服务器填上面地址即可
4. 推流后在应用中观看
```
**📋 复制推流地址**
- 点击 **"复制地址"** 按钮
- 这个地址稍后在 OBS 中使用
### 步骤 7查看直播间列表
- 点击 **"知道了"** 关闭对话框
- 你会看到刚创建的直播间
- 状态显示 **"未开播"**(灰色)
---
## OBS Studio 推流教程
### 步骤 1下载并安装 OBS
1. 访问 https://obsproject.com/download
2. 下载 Windows 版本
3. 安装(使用默认设置)
4. 启动 OBS Studio
### 步骤 2首次配置向导如果出现
如果是第一次打开 OBS会出现配置向导
1. **使用信息**:选择 "优化推流,录制次之"
2. **视频设置**
- 基础分辨率1920x1080
- FPS30
3. **推流信息**:选择 "我将使用自定义流媒体服务器"
4. 点击 **"下一步"** 完成
### 步骤 3配置推流设置
#### 3.1 打开设置
- 点击右下角 **"设置"** 按钮
- 或者菜单栏:**文件** → **设置**
#### 3.2 配置推流
1. 左侧选择 **"推流"**
2. **服务**:选择 "自定义..."
3. **服务器**:粘贴从 Android 应用复制的地址
```
rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2
```
**⚠️ 重要**
- 如果地址是 `rtmp://10.0.2.2:1935/...`,改成 `rtmp://localhost:1935/...`
- 完整地址包含 streamKey不需要单独填写串流密钥
4. **串流密钥**:留空(因为已经包含在服务器地址中)
5. 点击 **"应用"**
#### 3.3 配置输出(可选,优化画质)
1. 左侧选择 **"输出"**
2. **输出模式**:简单
3. **视频比特率**2500 Kbps
4. **编码器**x264
5. 点击 **"应用"**
#### 3.4 配置视频(可选)
1. 左侧选择 **"视频"**
2. **基础分辨率**1920x1080
3. **输出分辨率**1280x720
4. **常用 FPS 值**30
5. 点击 **"确定"**
### 步骤 4添加视频源
#### 方法 A添加摄像头
1. 在 **"来源"** 区域点击 **+** 号
2. 选择 **"视频捕获设备"**
3. 命名:例如 "摄像头"
4. 点击 **"确定"**
5. **设备**:选择你的摄像头
6. 点击 **"确定"**
#### 方法 B添加屏幕捕获
1. 在 **"来源"** 区域点击 **+** 号
2. 选择 **"显示器捕获"**
3. 命名:例如 "屏幕"
4. 点击 **"确定"**
5. **显示器**:选择要捕获的屏幕
6. 点击 **"确定"**
#### 方法 C添加窗口捕获
1. 在 **"来源"** 区域点击 **+** 号
2. 选择 **"窗口捕获"**
3. 命名:例如 "浏览器"
4. 点击 **"确定"**
5. **窗口**:选择要捕获的窗口
6. 点击 **"确定"**
### 步骤 5调整画面
- 拖动红色边框调整大小
- 拖动中心调整位置
- 右键源 → **变换****适应屏幕** 可以自动适配
### 步骤 6开始推流
1. 点击右下角 **"开始推流"** 按钮
2. 按钮变成 **"停止推流"**,底部状态栏显示:
```
直播中 | 00:00:15 | 2500 kb/s | 0 丢帧
```
**✅ 推流成功标志**
- 状态栏显示 "直播中"
- 有比特率数据(如 2500 kb/s
- 丢帧数为 0 或很少
**❌ 推流失败标志**
- 提示 "连接失败"
- 状态栏显示红色错误
- 检查推流地址是否正确
### 步骤 7在 Android 应用中观看
1. 回到 Android 模拟器
2. 在直播间列表中,你的直播间状态应该变成 **"直播中"**(红色)
3. 点击直播间进入
4. 等待 2-3 秒,视频开始播放
5. 你应该能看到 OBS 中的画面
---
## 完整操作流程
### 🎬 完整演示流程
#### 第一步启动所有服务5 分钟)
```bash
# 1. 启动 Docker如果未运行
cd live-streaming
docker-compose up -d
# 2. 启动后端服务器
node server/index.js
# 保持窗口打开!
```
#### 第二步:启动 Android 应用3 分钟)
1. 打开 Android Studio
2. 打开 `android-app` 项目
3. 启动模拟器
4. 运行应用(点击 ▶️)
#### 第三步创建直播间1 分钟)
1. 在 Android 应用中点击 **"开始直播"**
2. 填写标题和主播名称
3. 点击 **"创建"**
4. 点击 **"复制地址"** 复制推流地址
5. 点击 **"知道了"**
#### 第四步:配置 OBS2 分钟)
1. 打开 OBS Studio
2. 点击 **"设置"** → **"推流"**
3. 服务:选择 "自定义..."
4. 服务器:粘贴推流地址(改 `10.0.2.2``localhost`
5. 点击 **"确定"**
#### 第五步添加视频源2 分钟)
1. 点击 **"来源"** 区域的 **+** 号
2. 选择 **"视频捕获设备"**(摄像头)或 **"显示器捕获"**(屏幕)
3. 配置并确定
#### 第六步开始直播1 分钟)
1. 在 OBS 中点击 **"开始推流"**
2. 等待 2-3 秒
3. 回到 Android 模拟器
4. 点击直播间进入
5. 观看直播!🎉
---
## 常见问题解决
### ❌ 问题 1Android 应用显示"网络错误"
**原因**:后端服务器未启动
**解决**
```bash
cd live-streaming
node server/index.js
```
验证:浏览器访问 http://localhost:3001/health
---
### ❌ 问题 2OBS 提示"连接失败"
**原因 A**:推流地址错误
**解决**
1. 检查地址格式:`rtmp://localhost:1935/live/{streamKey}`
2. 确保使用 `localhost` 而不是 `10.0.2.2`
3. 确保包含完整的 streamKey
**原因 B**SRS 服务器未运行
**解决**
```bash
docker ps
# 如果没有 srs-server运行
cd live-streaming
docker-compose up -d
```
---
### ❌ 问题 3Android 应用显示"未开播"
**原因**:后端无法检测到推流
**解决**
1. 确认 OBS 正在推流(状态栏显示"直播中"
2. 检查后端日志是否有错误
3. 重启后端服务器
4. 在 Android 应用中下拉刷新
**验证推流状态**
浏览器访问 http://localhost:1985/api/v1/streams/
应该看到你的 streamKey
---
### ❌ 问题 4视频播放黑屏
**原因 A**HLS 转码未启用
**解决**
检查 `live-streaming/.env` 中是否有 FFmpeg 配置
```env
FFMPEG_PATH=C:\path\to\ffmpeg.exe
```
**原因 B**:网络延迟
**解决**
- 等待 5-10 秒
- 退出直播间重新进入
- 检查网络连接
**原因 C**Android 播放器不支持格式
**解决**
查看后端日志,确认 HLS 地址可用
---
### ❌ 问题 5模拟器无法连接到后端
**原因**:防火墙阻止
**解决**
```powershell
# 以管理员身份运行 PowerShell
netsh advfirewall firewall add rule name="Node.js API" dir=in action=allow protocol=TCP localport=3001
```
---
### ❌ 问题 6重启后端后房间消失
**原因**:已修复!现在房间会自动保存
**验证**
检查 `live-streaming/data/rooms.json` 文件是否存在
---
### ❌ 问题 7OBS 画面卡顿
**原因**:比特率过高或电脑性能不足
**解决**
1. OBS 设置 → 输出
2. 降低视频比特率2500 → 1500 Kbps
3. 降低分辨率1080p → 720p
4. 降低帧率60fps → 30fps
---
## 快速参考
### 端口说明
| 端口 | 服务 | 用途 |
|------|------|------|
| 1935 | RTMP | OBS 推流 |
| 8080 | HTTP-FLV/HLS | 视频播放 |
| 1985 | SRS API | 推流状态检测 |
| 3001 | Node.js API | Android 应用接口 |
### 关键地址
| 用途 | 地址 |
|------|------|
| OBS 推流 | `rtmp://localhost:1935/live/{streamKey}` |
| Android API | `http://10.0.2.2:3001/api/` |
| 健康检查 | `http://localhost:3001/health` |
| SRS 状态 | `http://localhost:1985/api/v1/streams/` |
### 常用命令
```bash
# 启动后端
cd live-streaming
node server/index.js
# 启动 Docker
docker-compose up -d
# 查看 Docker 容器
docker ps
# 停止 Docker
docker-compose down
# 查看端口占用
netstat -ano | findstr :3001
```
---
## 视频教程(建议录制)
如果你想录制视频教程,可以按照以下脚本:
### 📹 脚本大纲
1. **开场**30秒
- "大家好,今天教大家如何搭建一个 Android 直播系统"
- 展示最终效果OBS 推流 → Android 观看
2. **环境准备**2分钟
- 展示必需软件
- 检查 Docker 运行状态
3. **启动后端**1分钟
- 打开命令行
- 运行 `node server/index.js`
- 展示成功日志
4. **Android Studio**3分钟
- 打开项目
- 启动模拟器
- 运行应用
- 创建直播间
- 复制推流地址
5. **OBS 配置**3分钟
- 打开设置
- 配置推流地址
- 添加视频源
- 开始推流
6. **观看直播**1分钟
- 回到 Android 应用
- 点击直播间
- 展示播放效果
7. **结尾**30秒
- 总结关键步骤
- 提醒常见问题
---
## 下一步学习
### 🚀 进阶功能
- [ ] 添加聊天功能
- [ ] 添加礼物打赏
- [ ] 添加观众人数统计
- [ ] 支持多个直播间
- [ ] 添加直播回放
### 📚 推荐资源
- OBS 官方文档https://obsproject.com/wiki/
- SRS 文档https://ossrs.net/
- Android ExoPlayerhttps://exoplayer.dev/
---
## 技术支持
如果遇到问题:
1. 查看本教程的"常见问题解决"部分
2. 查看 `android-app/问题修复总结.md`
3. 查看后端日志输出
4. 查看 Android Studio 的 Logcat
---
**祝你使用愉快!🎉**

336
如何获取RTMP地址.md Normal file
View File

@ -0,0 +1,336 @@
# 如何获取 RTMP 推流地址
## 方法 1通过 Android 应用获取(最简单)✅
### 步骤 1启动后端服务器
```bash
cd live-streaming
node server/index.js
```
**重要**:保持这个窗口打开!
### 步骤 2在 Android 应用中创建直播间
1. 打开 Android 应用(在模拟器中)
2. 点击右上角的 **"开始直播"** 按钮
![开始直播按钮位置](位置:屏幕右上角)
3. 填写直播间信息:
```
直播间标题:我的直播间
主播名称:小明
```
4. 点击 **"创建"** 按钮
### 步骤 3获取 RTMP 地址
创建成功后会弹出对话框,显示:
```
推流地址:
rtmp://10.0.2.2:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2
电脑本机 OBS 可用:
rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2
```
### 步骤 4复制地址
- 点击 **"复制地址"** 按钮
- 地址已复制到剪贴板
- 可以直接粘贴到 OBS 中使用
### ⚠️ 重要提示
**在 OBS 中使用时**
- ✅ 使用:`rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2`
- ❌ 不要用:`rtmp://10.0.2.2:1935/live/...`(这是给 Android 模拟器用的)
**地址说明**
- `rtmp://` - 协议
- `localhost` - 本机地址
- `1935` - RTMP 端口
- `/live/` - 应用名称
- `868c49cc-1021-4664-95a3-ed71e789adb2` - 你的 streamKey每个直播间不同
---
## 方法 2查看已创建的直播间
如果你已经创建过直播间,想再次查看 RTMP 地址:
### 在 Android 应用中查看
1. 打开应用,在首页看到直播间列表
2. 点击任意直播间进入详情页
3. 向下滚动,找到 **"推流信息"** 区域
4. 可以看到:
```
推流地址
rtmp://10.0.2.2:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2
电脑本机 OBS 可用(等价地址)
rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2
推流密钥
868c49cc-1021-4664-95a3-ed71e789adb2
```
5. 点击 **"复制地址"** 按钮
---
## 方法 3通过 API 获取(技术方式)
如果你想通过代码或命令行获取:
### 查看所有直播间
```bash
curl http://localhost:3001/api/rooms
```
返回示例:
```json
{
"success": true,
"data": [
{
"id": "868c49cc-1021-4664-95a3-ed71e789adb2",
"title": "我的直播间",
"streamerName": "小明",
"streamKey": "868c49cc-1021-4664-95a3-ed71e789adb2",
"isLive": false,
"streamUrls": {
"rtmp": "rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2",
"flv": "http://localhost:8080/live/868c49cc-1021-4664-95a3-ed71e789adb2.flv",
"hls": "http://localhost:8080/live/868c49cc-1021-4664-95a3-ed71e789adb2.m3u8"
}
}
]
}
```
### 查看特定直播间
```bash
curl http://localhost:3001/api/rooms/868c49cc-1021-4664-95a3-ed71e789adb2
```
---
## 方法 4查看持久化文件
直播间数据保存在文件中,可以直接查看:
### 文件位置
```
live-streaming/data/rooms.json
```
### 打开文件
用记事本或 VS Code 打开,内容示例:
```json
[
{
"id": "868c49cc-1021-4664-95a3-ed71e789adb2",
"title": "我的直播间",
"streamerName": "小明",
"streamKey": "868c49cc-1021-4664-95a3-ed71e789adb2",
"isLive": false,
"viewerCount": 0,
"createdAt": "2025-12-16T09:20:00.000Z",
"startedAt": null
}
]
```
### 构建 RTMP 地址
根据 `streamKey` 构建:
```
rtmp://localhost:1935/live/{streamKey}
```
例如:
```
rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2
```
---
## 在 OBS 中使用 RTMP 地址
### 方式 A完整地址推荐
1. 打开 OBS Studio
2. 点击 **"设置"** → **"推流"**
3. **服务**:选择 "自定义..."
4. **服务器**:粘贴完整地址
```
rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2
```
5. **串流密钥**:留空
6. 点击 **"确定"**
### 方式 B分开填写
1. 打开 OBS Studio
2. 点击 **"设置"** → **"推流"**
3. **服务**:选择 "自定义..."
4. **服务器**
```
rtmp://localhost:1935/live
```
5. **串流密钥**
```
868c49cc-1021-4664-95a3-ed71e789adb2
```
6. 点击 **"确定"**
**两种方式效果相同**,选择你喜欢的即可。
---
## 快速参考
### RTMP 地址格式
```
rtmp://[主机]:[端口]/[应用]/[streamKey]
```
### 示例
```
rtmp://localhost:1935/live/868c49cc-1021-4664-95a3-ed71e789adb2
│ │ │ │ │
│ │ │ │ └─ streamKey每个直播间唯一
│ │ │ └────── 应用名称(固定为 live
│ │ └──────────── RTMP 端口(固定为 1935
│ └────────────────────── 主机地址OBS 用 localhost
└───────────────────────────── 协议(固定为 rtmp
```
### 地址对照表
| 使用场景 | 地址 |
|---------|------|
| OBS 推流 | `rtmp://localhost:1935/live/{streamKey}` |
| Android 模拟器 | `rtmp://10.0.2.2:1935/live/{streamKey}` |
| 局域网其他设备 | `rtmp://192.168.x.x:1935/live/{streamKey}` |
---
## 常见问题
### ❓ 为什么有两个不同的地址?
**答**
- `rtmp://localhost:1935/...` - 给电脑上的 OBS 用
- `rtmp://10.0.2.2:1935/...` - 给 Android 模拟器用
在 Android 模拟器中,`10.0.2.2` 代表宿主机(你的电脑)的 `localhost`
### ❓ streamKey 是什么?
**答**streamKey 是每个直播间的唯一标识符,就像房间号。每创建一个直播间,系统会自动生成一个新的 streamKey。
### ❓ 可以自定义 streamKey 吗?
**答**:当前版本不支持自定义,系统会自动生成 UUID 作为 streamKey。如果需要自定义需要修改后端代码。
### ❓ 忘记了 RTMP 地址怎么办?
**答**
1. 在 Android 应用中点击直播间查看
2. 或者查看 `live-streaming/data/rooms.json` 文件
3. 或者通过 API 查询:`curl http://localhost:3001/api/rooms`
### ❓ 可以同时创建多个直播间吗?
**答**:可以!每个直播间有独立的 streamKey可以同时推流到不同的直播间。
### ❓ RTMP 地址会变吗?
**答**不会只要不删除直播间RTMP 地址streamKey就不会改变。即使重启服务器地址也会保持不变因为已经持久化保存
---
## 图文演示
### 📱 Android 应用界面
```
┌─────────────────────────────┐
│ 直播系统 [开始直播] │ ← 点击这里
├─────────────────────────────┤
│ │
│ ┌─────────────────────┐ │
│ │ 我的直播间 │ │
│ │ 主播: 小明 │ │
│ │ [直播中] │ │ ← 点击进入查看地址
│ └─────────────────────┘ │
│ │
└─────────────────────────────┘
```
### 📋 创建直播间对话框
```
┌─────────────────────────────┐
│ 创建直播间 │
├─────────────────────────────┤
│ │
│ 直播间标题 │
│ ┌─────────────────────┐ │
│ │ 我的直播间 │ │
│ └─────────────────────┘ │
│ │
│ 主播名称 │
│ ┌─────────────────────┐ │
│ │ 小明 │ │
│ └─────────────────────┘ │
│ │
│ [取消] [创建] │ ← 点击创建
└─────────────────────────────┘
```
### 📺 推流信息对话框
```
┌─────────────────────────────┐
│ 已创建直播间 │
├─────────────────────────────┤
│ │
│ 推流地址: │
│ rtmp://10.0.2.2:1935/... │
│ │
│ 电脑本机 OBS 可用: │
│ rtmp://localhost:1935/... │ ← 这个地址用于 OBS
│ │
│ ⚠️ 注意: │
│ 1. Android 应用只能观看 │
│ 2. 需要用 OBS 在电脑上推流 │
│ 3. OBS 设置:服务器填上面 │
│ 4. 推流后在应用中观看 │
│ │
│ [复制地址] [知道了] │ ← 点击复制
└─────────────────────────────┘
```
---
## 总结
### 最简单的方法 🎯
1. **打开 Android 应用**
2. **点击"开始直播"**
3. **填写信息并创建**
4. **点击"复制地址"**
5. **粘贴到 OBS 中**(记得把 `10.0.2.2` 改成 `localhost`
就这么简单!🎉
---
**需要帮助?**
- 查看 `使用教程.md` 获取完整教程
- 查看 `android-app/问题修复总结.md` 解决常见问题

438
推流问题完整诊断.md Normal file
View File

@ -0,0 +1,438 @@
# 推流问题完整诊断与解决
## 🔍 当前状态
- ✅ 后端服务器运行正常(端口 3001
- ✅ SRS 服务器运行正常(端口 1935
- ✅ 直播间已创建streamKey: 7f4acb94-f91c-4ec0-84eb-fbb1855f8f18
- ❌ **SRS 没有收到推流**streams 数组为空)
- ❌ **Android 应用显示"未开播"**
## 🎯 问题原因
**OBS 的推流没有成功到达 SRS 服务器!**
可能的原因:
1. OBS 推流地址配置错误
2. OBS 推流失败但没有提示
3. OBS 没有真正开始推流
4. 防火墙阻止了连接
---
## 🔧 完整诊断步骤
### 步骤 1检查 OBS 推流状态
#### 1.1 查看 OBS 界面
在 OBS 右下角查看:
**如果推流成功**
```
直播中 | 00:01:23 | 2500 kb/s | 0 丢帧
```
**如果推流失败**
```
未推流
```
或显示红色错误提示
#### 1.2 查看 OBS 日志
1. 点击 OBS 菜单栏:**帮助** → **日志文件** → **查看当前日志**
2. 搜索 "error" 或 "failed"
3. 查看是否有连接错误
### 步骤 2验证 OBS 推流设置
#### 2.1 打开推流设置
1. 点击 **"设置"** → **"推流"**
2. 检查配置
#### 2.2 正确的配置应该是
**方式 A完整地址**
```
服务: 自定义...
服务器: rtmp://localhost:1935/live/7f4acb94-f91c-4ec0-84eb-fbb1855f8f18
串流密钥: (留空)
```
**方式 B分开填写**
```
服务: 自定义...
服务器: rtmp://localhost:1935/live
串流密钥: 7f4acb94-f91c-4ec0-84eb-fbb1855f8f18
```
#### 2.3 常见错误
**错误 1**:使用了 `10.0.2.2`
```
服务器: rtmp://10.0.2.2:1935/live/... ← 错误!
```
**错误 2**streamKey 不完整
```
串流密钥: 7f4acb94-f91c-4ec0 ← 不完整!
```
**错误 3**:多了空格或换行
```
服务器: rtmp://localhost:1935/live/7f4acb94-f91c-4ec0-84eb-fbb1855f8f18
↑ 多了空格
```
### 步骤 3重新配置 OBS
#### 3.1 停止当前推流
1. 如果 OBS 显示"直播中",点击 **"停止推流"**
2. 等待完全停止
#### 3.2 清空并重新配置
1. 点击 **"设置"** → **"推流"**
2. **服务器** 框中,删除所有内容
3. 重新输入(不要复制粘贴,手动输入):
```
rtmp://localhost:1935/live/7f4acb94-f91c-4ec0-84eb-fbb1855f8f18
```
4. **串流密钥**:确保为空
5. 点击 **"应用"**
6. 点击 **"确定"**
#### 3.3 测试连接(可选)
有些 OBS 版本有"测试连接"按钮,如果有就点击测试。
### 步骤 4开始推流并验证
#### 4.1 确保有视频源
1. 在 OBS 的"来源"区域,确保至少有一个视频源
2. 如果没有,添加一个:
- 点击 + 号
- 选择"视频捕获设备"(摄像头)或"显示器捕获"(屏幕)
#### 4.2 开始推流
1. 点击 **"开始推流"** 按钮
2. 观察状态栏变化
#### 4.3 立即验证(推流后 5 秒内)
**方法 1浏览器检查 SRS**
打开浏览器访问:
```
http://localhost:1985/api/v1/streams/
```
**成功时应该看到**
```json
{
"code": 0,
"streams": [
{
"id": "vid-xxxxx",
"name": "7f4acb94-f91c-4ec0-84eb-fbb1855f8f18",
"app": "live",
"publish": {
"active": true,
"cid": "xxxxx"
}
}
]
}
```
**失败时会看到**
```json
{
"code": 0,
"streams": []
}
```
**方法 2检查后端 API**
打开浏览器访问:
```
http://localhost:3001/api/rooms/7f4acb94-f91c-4ec0-84eb-fbb1855f8f18
```
查看 `isLive` 字段:
```json
{
"isLive": true ← 应该是 true
}
```
**方法 3Android 应用**
1. 在 Android 模拟器中下拉刷新
2. 查看直播间状态是否变成"直播中"
---
## 🐛 如果还是失败
### 诊断 A检查防火墙
Windows 防火墙可能阻止了 OBS 连接到 SRS。
#### 解决方法:
以管理员身份运行 PowerShell执行
```powershell
netsh advfirewall firewall add rule name="RTMP Server" dir=in action=allow protocol=TCP localport=1935
netsh advfirewall firewall add rule name="RTMP Server Out" dir=out action=allow protocol=TCP localport=1935
```
### 诊断 B检查 SRS 日志
#### 查看 Docker 日志:
```bash
docker logs srs-server --tail 50
```
查找是否有连接尝试或错误信息。
### 诊断 C使用简化的测试地址
#### 测试 1使用最简单的配置
在 OBS 中:
```
服务器: rtmp://localhost:1935/live
串流密钥: test123
```
然后开始推流,访问:
```
http://localhost:1985/api/v1/streams/
```
如果能看到 `"name": "test123"`,说明 OBS 和 SRS 连接正常,问题在于 streamKey。
### 诊断 D检查 OBS 版本
#### 确认 OBS 版本
1. 点击 **帮助** → **关于**
2. 确认版本号(推荐 28.0 或更高)
#### 如果版本太旧
下载最新版本https://obsproject.com/download
### 诊断 E重启所有服务
#### 完全重启流程:
```bash
# 1. 停止 OBS 推流
# 2. 关闭 OBS
# 3. 重启 SRS Docker
docker restart srs-server
# 4. 重启后端服务器
# 在命令行按 Ctrl+C 停止,然后重新运行:
cd live-streaming
node server/index.js
# 5. 等待 5 秒
# 6. 重新打开 OBS
# 7. 重新配置推流地址
# 8. 开始推流
```
---
## 📋 完整的正确操作流程(从头开始)
### 1. 确保所有服务运行
#### 检查 Docker
```bash
docker ps
```
应该看到 `srs-server` 在运行。
#### 启动后端
```bash
cd live-streaming
node server/index.js
```
### 2. 在 Android 应用中创建直播间
1. 打开应用
2. 点击"开始直播"
3. 填写:
- 标题:测试直播
- 主播:小明
4. 点击"创建"
5. **记下完整的 streamKey**例如7f4acb94-f91c-4ec0-84eb-fbb1855f8f18
### 3. 配置 OBS重要
1. 打开 OBS Studio
2. 点击 **"设置"**
3. 左侧选择 **"推流"**
4. **服务**:下拉选择 **"自定义..."**
5. **服务器**:输入(注意不要有空格)
```
rtmp://localhost:1935/live/7f4acb94-f91c-4ec0-84eb-fbb1855f8f18
```
6. **串流密钥**:留空(不要填任何东西)
7. 点击 **"应用"**
8. 点击 **"确定"**
### 4. 添加视频源(如果还没有)
1. 在"来源"区域点击 **+** 号
2. 选择 **"显示器捕获"**
3. 命名:屏幕
4. 点击 **"确定"**
5. 选择你的显示器
6. 点击 **"确定"**
### 5. 开始推流
1. 点击 **"开始推流"**
2. 观察右下角状态栏
**成功标志**
```
直播中 | 00:00:05 | 2500 kb/s | 0 丢帧
```
**失败标志**
- 提示"连接失败"
- 状态栏显示红色
- 没有比特率数据
### 6. 验证推流(推流后立即检查)
#### 方法 1浏览器
访问http://localhost:1985/api/v1/streams/
应该看到你的 streamKey。
#### 方法 2Android 应用
1. 下拉刷新列表
2. 应该显示"直播中"(红色)
3. 点击进入观看
---
## 🎥 OBS 推流设置截图说明
### 正确的设置界面
```
┌──────────────────────────────────────────┐
│ 设置 │
├──────────────────────────────────────────┤
│ 推流 │
│ │
│ 服务: [自定义... ▼] │
│ │
│ 服务器: │
│ ┌────────────────────────────────────┐ │
│ │rtmp://localhost:1935/live/7f4acb94-│ │
│ │f91c-4ec0-84eb-fbb1855f8f18 │ │
│ └────────────────────────────────────┘ │
│ │
│ 串流密钥: │
│ ┌────────────────────────────────────┐ │
│ │ │ │ ← 留空!
│ └────────────────────────────────────┘ │
│ │
│ □ 使用身份验证 │
│ │
│ [取消] [确定] │
└──────────────────────────────────────────┘
```
---
## 📞 调试命令速查
### 检查 SRS 推流状态
```bash
curl http://localhost:1985/api/v1/streams/
```
### 检查直播间状态
```bash
curl http://localhost:3001/api/rooms/7f4acb94-f91c-4ec0-84eb-fbb1855f8f18
```
### 检查 Docker 状态
```bash
docker ps
docker logs srs-server --tail 20
```
### 检查端口
```bash
netstat -ano | findstr :1935
netstat -ano | findstr :3001
```
### 重启 SRS
```bash
docker restart srs-server
```
---
## ⚠️ 关键注意事项
### ✅ 必须做对的事情
1. **地址必须用 localhost**
- ✅ `rtmp://localhost:1935/...`
- ❌ `rtmp://10.0.2.2:1935/...`
2. **streamKey 必须完整**
- ✅ `7f4acb94-f91c-4ec0-84eb-fbb1855f8f18`
- ❌ `7f4acb94-f91c-4ec0`
3. **串流密钥框必须为空**
- 因为 streamKey 已经包含在服务器地址中了
4. **推流后立即验证**
- 不要等太久,推流后 5 秒内就应该能在 SRS 中看到
### ❌ 常见错误
1. 复制地址时带了空格或换行
2. 使用了 `10.0.2.2` 而不是 `localhost`
3. streamKey 不完整或错误
4. 在"串流密钥"框中又填了一遍 streamKey
5. 防火墙阻止了连接
---
## 💡 快速测试方法
如果你想快速测试 OBS 和 SRS 是否能正常连接:
### 简化测试
1. OBS 设置:
```
服务器: rtmp://localhost:1935/live
串流密钥: test
```
2. 开始推流
3. 访问http://localhost:1985/api/v1/streams/
4. 如果能看到 `"name": "test"`,说明连接正常
5. 然后再改回正确的 streamKey
---
**现在按照上面的步骤重新配置 OBS特别注意地址格式** 🚀
如果还是不行,请告诉我:
1. OBS 状态栏显示什么?
2. 访问 http://localhost:1985/api/v1/streams/ 看到什么?
3. OBS 日志中有什么错误?