更新:添加用户和主播切换功能

This commit is contained in:
xiao12feng8 2026-01-03 12:24:05 +08:00
parent 7042137e5b
commit f3169b2eba
43 changed files with 3425 additions and 244 deletions

View File

@ -147,3 +147,40 @@ export function roomUpdateApi(data) {
data
})
}
// ========== 房间分类管理 ==========
// 分类列表
export function roomCategoryListApi(params) {
return request({
url: '/admin/room/category/list',
method: 'get',
params
})
}
// 保存分类(新增/编辑)
export function roomCategorySaveApi(data) {
return request({
url: '/admin/room/category/save',
method: 'post',
data
})
}
// 删除分类
export function roomCategoryDeleteApi(id) {
return request({
url: `/admin/room/category/delete/${id}`,
method: 'post'
})
}
// 更新分类状态
export function roomCategoryUpdateStatusApi(id, status) {
return request({
url: '/admin/room/category/updateStatus',
method: 'post',
params: { id, status }
})
}

View File

@ -15,25 +15,13 @@ const liveManageRouter = {
icon: 'el-icon-video-camera',
},
children: [
// 房间管理
// 房间管理(包含分类管理)
{
path: 'room/list',
component: () => import('@/views/room/list/index'),
name: 'RoomList',
meta: { title: '房间列表', icon: '' },
},
{
path: 'room/type',
component: () => import('@/views/room/type/index'),
name: 'RoomType',
meta: { title: '房间类型', icon: '' },
},
{
path: 'room/background',
component: () => import('@/views/room/background/index'),
name: 'RoomBackground',
meta: { title: '房间背景', icon: '' },
},
// 家族管理
{
path: 'family/list',

View File

@ -2,100 +2,141 @@
<div class="divBox">
<el-card shadow="never" class="ivu-mt">
<div class="padding-add">
<el-page-header @back="goBack" content="房间管理列表"></el-page-header>
<div class="mt20">
<div class="mb20" style="display: flex; justify-content: space-between; align-items: center;">
<div>
<el-button type="danger" icon="el-icon-delete" :disabled="!multipleSelection.length" @click="handleBatchDelete">批量删除</el-button>
<el-button type="primary" icon="el-icon-plus" @click="openCreate">新增直播间</el-button>
</div>
<el-button type="text" icon="el-icon-refresh" @click="getList">刷新</el-button>
</div>
<!-- 搜索表单 -->
<el-form inline size="small" :model="searchForm" class="mb20 search-form-inline">
<el-form-item>
<el-input v-model="searchForm.streamerName" placeholder="请输入主播名称" clearable style="width: 150px" />
</el-form-item>
<el-form-item>
<el-input v-model="searchForm.title" placeholder="请输入直播标题" clearable style="width: 150px" />
</el-form-item>
<el-form-item>
<el-input v-model="searchForm.streamKey" placeholder="请输入 streamKey" clearable style="width: 150px" />
</el-form-item>
<el-form-item>
<el-date-picker v-model="searchForm.startTime" type="date" placeholder="开始时间" style="width: 150px"></el-date-picker>
</el-form-item>
<el-form-item>
<el-date-picker v-model="searchForm.endTime" type="date" placeholder="截止时间" style="width: 150px"></el-date-picker>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
<el-page-header @back="goBack" content="房间管理"></el-page-header>
<!-- 表格 -->
<el-table :data="tableData" v-loading="loading" border style="width: 100%" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="id" label="房间ID" width="80" align="center" sortable />
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="streamerName" label="主播" width="120" />
<el-table-column prop="streamKey" label="streamKey" min-width="220" />
<el-table-column label="直播状态" width="100" align="center">
<template slot-scope="scope">
<span>{{ scope.row.isLive ? '直播中' : '未开播' }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="160" align="center" />
<el-table-column label="播放地址(FLV)" min-width="260">
<template slot-scope="scope">
<span>{{ scope.row.streamUrls && scope.row.streamUrls.flv }}</span>
</template>
</el-table-column>
<el-table-column label="播放地址(HLS)" min-width="260">
<template slot-scope="scope">
<span>{{ scope.row.streamUrls && scope.row.streamUrls.hls }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="320" align="center" fixed="right">
<template slot-scope="scope">
<div class="action-btns">
<el-button :type="scope.row.isLive ? 'danger' : 'success'" size="mini" @click="handleToggleStatus(scope.row)">{{ scope.row.isLive ? '停播' : '开播' }}</el-button>
<el-button type="primary" size="mini" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="warning" size="mini" @click="handleDetail(scope.row)">详情</el-button>
<el-popconfirm title="确定删除吗?" @confirm="handleDelete(scope.row)">
<el-button slot="reference" type="danger" size="mini">删除</el-button>
</el-popconfirm>
<!-- Tab切换房间列表 / 分类管理 -->
<el-tabs v-model="activeTab" class="mt20">
<el-tab-pane label="房间列表" name="rooms">
<div class="mt20">
<div class="mb20" style="display: flex; justify-content: space-between; align-items: center;">
<div>
<el-button type="danger" icon="el-icon-delete" :disabled="!multipleSelection.length" @click="handleBatchDelete">批量删除</el-button>
<el-button type="primary" icon="el-icon-plus" @click="openCreate">新增直播间</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="acea-row row-right page mt20">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="searchForm.page"
:page-sizes="[10, 20, 50, 100]"
:page-size="searchForm.limit"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
></el-pagination>
</div>
</div>
<el-button type="text" icon="el-icon-refresh" @click="getList">刷新</el-button>
</div>
<!-- 搜索表单 -->
<el-form inline size="small" :model="searchForm" class="mb20 search-form-inline">
<el-form-item>
<el-input v-model="searchForm.streamerName" placeholder="请输入主播名称" clearable style="width: 150px" />
</el-form-item>
<el-form-item>
<el-input v-model="searchForm.title" placeholder="请输入直播标题" clearable style="width: 150px" />
</el-form-item>
<el-form-item>
<el-select v-model="searchForm.categoryId" placeholder="选择分类" clearable style="width: 120px">
<el-option v-for="cat in categoryList" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleSearch">查询</el-button>
</el-form-item>
</el-form>
<!-- 表格 -->
<el-table :data="tableData" v-loading="loading" border style="width: 100%" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="id" label="房间ID" width="80" align="center" sortable />
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="categoryName" label="分类" width="100" align="center">
<template slot-scope="scope">
<el-tag size="small" v-if="scope.row.categoryName">{{ scope.row.categoryName }}</el-tag>
<span v-else style="color: #999;">未分类</span>
</template>
</el-table-column>
<el-table-column prop="streamerName" label="主播" width="120" />
<el-table-column prop="streamKey" label="streamKey" min-width="200" />
<el-table-column label="直播状态" width="100" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.isLive ? 'success' : 'info'" size="small">{{ scope.row.isLive ? '直播中' : '未开播' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="160" align="center" />
<el-table-column label="操作" width="280" align="center" fixed="right">
<template slot-scope="scope">
<div class="action-btns">
<el-button :type="scope.row.isLive ? 'danger' : 'success'" size="mini" @click="handleToggleStatus(scope.row)">{{ scope.row.isLive ? '停播' : '开播' }}</el-button>
<el-button type="primary" size="mini" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="warning" size="mini" @click="handleDetail(scope.row)">详情</el-button>
<el-popconfirm title="确定删除吗?" @confirm="handleDelete(scope.row)">
<el-button slot="reference" type="danger" size="mini">删除</el-button>
</el-popconfirm>
</div>
</template>
</el-table-column>
</el-table>
<div class="acea-row row-right page mt20">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="searchForm.page"
:page-sizes="[10, 20, 50, 100]"
:page-size="searchForm.limit"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
></el-pagination>
</div>
</div>
</el-tab-pane>
<!-- 分类管理 Tab -->
<el-tab-pane label="分类管理" name="categories">
<div class="mt20">
<div class="mb20" style="display: flex; justify-content: space-between; align-items: center;">
<el-button type="primary" icon="el-icon-plus" @click="openCategoryDialog()">新增分类</el-button>
<el-button type="text" icon="el-icon-refresh" @click="loadCategories">刷新</el-button>
</div>
<el-table :data="categoryList" v-loading="categoryLoading" border style="width: 100%">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="分类名称" min-width="150" />
<el-table-column prop="icon" label="图标" width="100" align="center">
<template slot-scope="scope">
<i :class="scope.row.icon" style="font-size: 20px;" v-if="scope.row.icon && scope.row.icon.startsWith('el-icon')"></i>
<img :src="scope.row.icon" style="width: 30px; height: 30px;" v-else-if="scope.row.icon" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="状态" width="100" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0" @change="handleCategoryStatusChange(scope.row)" />
</template>
</el-table-column>
<el-table-column prop="roomCount" label="房间数" width="100" align="center">
<template slot-scope="scope">
<el-tag size="small">{{ scope.row.roomCount || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="160" align="center" />
<el-table-column label="操作" width="150" align="center">
<template slot-scope="scope">
<el-button type="primary" size="mini" @click="openCategoryDialog(scope.row)">编辑</el-button>
<el-popconfirm title="确定删除该分类吗?" @confirm="handleDeleteCategory(scope.row)">
<el-button slot="reference" type="danger" size="mini">删除</el-button>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-card>
<!-- 详情弹窗 -->
<el-dialog title="详情" :visible.sync="detailVisible" width="800px">
<el-descriptions :column="3" border>
<el-dialog title="房间详情" :visible.sync="detailVisible" width="800px">
<el-descriptions :column="2" border>
<el-descriptions-item label="房间ID">{{ detailData.id }}</el-descriptions-item>
<el-descriptions-item label="标题">{{ detailData.title }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ detailData.categoryName || '未分类' }}</el-descriptions-item>
<el-descriptions-item label="主播">{{ detailData.streamerName }}</el-descriptions-item>
<el-descriptions-item label="streamKey">{{ detailData.streamKey }}</el-descriptions-item>
<el-descriptions-item label="直播状态">{{ detailData.isLive ? '直播中' : '未开播' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detailData.createTime }}</el-descriptions-item>
<el-descriptions-item label="RTMP">{{ detailData.streamUrls && detailData.streamUrls.rtmp }}</el-descriptions-item>
<el-descriptions-item label="FLV">{{ detailData.streamUrls && detailData.streamUrls.flv }}</el-descriptions-item>
<el-descriptions-item label="HLS">{{ detailData.streamUrls && detailData.streamUrls.hls }}</el-descriptions-item>
<el-descriptions-item label="RTMP推流地址" :span="2">{{ detailData.streamUrls && detailData.streamUrls.rtmp }}</el-descriptions-item>
<el-descriptions-item label="FLV播放地址" :span="2">{{ detailData.streamUrls && detailData.streamUrls.flv }}</el-descriptions-item>
<el-descriptions-item label="HLS播放地址" :span="2">{{ detailData.streamUrls && detailData.streamUrls.hls }}</el-descriptions-item>
</el-descriptions>
<!-- 弹幕记录 -->
@ -117,34 +158,42 @@
</div>
</el-dialog>
<!-- 编辑弹窗 -->
<el-dialog title="编辑" :visible.sync="editVisible" width="500px">
<el-form :model="editForm" label-width="120px">
<el-form-item label="房间状态">
<el-select v-model="editForm.status" placeholder="请选择" style="width: 100%">
<el-option label="正常" value="正常"></el-option>
<el-option label="禁用" value="禁用"></el-option>
<!-- 编辑房间弹窗 -->
<el-dialog title="编辑房间" :visible.sync="editVisible" width="500px">
<el-form :model="editForm" label-width="100px">
<el-form-item label="直播标题">
<el-input v-model="editForm.title" placeholder="请输入直播标题" />
</el-form-item>
<el-form-item label="房间分类">
<el-select v-model="editForm.categoryId" placeholder="请选择分类" style="width: 100%" clearable>
<el-option v-for="cat in categoryList" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</el-form-item>
<el-form-item label="推荐状态">
<el-select v-model="editForm.recommendStatus" placeholder="请选择" style="width: 100%">
<el-option label="未推荐" value="未推荐"></el-option>
<el-option label="已推荐" value="已推荐"></el-option>
<el-form-item label="房间状态">
<el-select v-model="editForm.status" placeholder="请选择" style="width: 100%">
<el-option label="正常" :value="1"></el-option>
<el-option label="禁用" :value="0"></el-option>
</el-select>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="editVisible = false">返回</el-button>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveEdit">保存</el-button>
</div>
</el-dialog>
<!-- 新增直播间弹窗 -->
<el-dialog title="新增直播间" :visible.sync="createVisible" width="520px">
<el-form :model="createForm" label-width="120px">
<el-form-item label="直播标题">
<el-form :model="createForm" label-width="100px">
<el-form-item label="直播标题" required>
<el-input v-model="createForm.title" placeholder="请输入直播标题" />
</el-form-item>
<el-form-item label="选择主播">
<el-form-item label="房间分类">
<el-select v-model="createForm.categoryId" placeholder="请选择分类" style="width: 100%" clearable>
<el-option v-for="cat in categoryList" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</el-form-item>
<el-form-item label="选择主播" required>
<el-select v-model="createForm.uid" filterable placeholder="请选择主播" style="width: 100%" @focus="loadStreamerOptions">
<el-option v-for="item in streamerOptions" :key="item.userId" :label="item.nickname + ' (ID:' + item.userId + ')'" :value="item.userId">
<div style="display: flex; align-items: center;">
@ -161,23 +210,52 @@
<el-button type="primary" :loading="createLoading" @click="handleCreate">创建</el-button>
</div>
</el-dialog>
<!-- 分类编辑弹窗 -->
<el-dialog :title="categoryForm.id ? '编辑分类' : '新增分类'" :visible.sync="categoryDialogVisible" width="450px">
<el-form :model="categoryForm" label-width="80px">
<el-form-item label="分类名称" required>
<el-input v-model="categoryForm.name" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="图标">
<el-input v-model="categoryForm.icon" placeholder="图标类名或图片URL">
<template slot="prepend">
<i :class="categoryForm.icon" v-if="categoryForm.icon && categoryForm.icon.startsWith('el-icon')"></i>
<span v-else>图标</span>
</template>
</el-input>
<div class="icon-tips">支持 Element UI 图标类名 el-icon-video-camera或图片URL</div>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="categoryForm.sort" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="categoryForm.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="categoryDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="categorySaving" @click="handleSaveCategory">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { roomListApi, liveRoomCreateApi, liveRoomToggleStatusApi, liveRoomChatHistoryApi } from '@/api/room';
import { roomListApi, liveRoomCreateApi, liveRoomToggleStatusApi, liveRoomChatHistoryApi, liveRoomUpdateApi } from '@/api/room';
import { getStreamerOptions } from '@/api/streamer';
import { roomCategoryListApi, roomCategorySaveApi, roomCategoryDeleteApi, roomCategoryUpdateStatusApi } from '@/api/room';
export default {
name: 'RoomList',
data() {
return {
activeTab: 'rooms',
//
searchForm: {
title: '',
streamerName: '',
streamKey: '',
startTime: '',
endTime: '',
categoryId: null,
page: 1,
limit: 20
},
@ -193,23 +271,41 @@ export default {
chatLoading: false,
multipleSelection: [],
editForm: {
status: '正常',
recommendStatus: '未推荐',
id: null,
title: '',
categoryId: null,
status: 1,
},
createForm: {
title: '',
categoryId: null,
uid: null,
},
streamerOptions: [],
//
categoryList: [],
categoryLoading: false,
categoryDialogVisible: false,
categorySaving: false,
categoryForm: {
id: null,
name: '',
icon: '',
sort: 0,
status: 1,
},
};
},
mounted() {
this.getList();
this.loadCategories();
},
methods: {
// ========== ==========
openCreate() {
this.createForm = {
title: '',
categoryId: null,
uid: null,
};
this.createVisible = true;
@ -228,7 +324,6 @@ export default {
this.$message.error('请填写直播标题并选择主播');
return;
}
//
const selectedStreamer = this.streamerOptions.find(s => s.userId === this.createForm.uid);
const streamerName = selectedStreamer ? selectedStreamer.nickname : '';
@ -236,10 +331,12 @@ export default {
try {
const res = await liveRoomCreateApi({
title: this.createForm.title,
categoryId: this.createForm.categoryId,
uid: this.createForm.uid,
streamerName: streamerName,
});
this.createVisible = false;
this.$message.success('创建成功');
await this.getList();
this.handleDetail(res);
} catch (error) {
@ -256,7 +353,7 @@ export default {
limit: this.searchForm.limit,
title: this.searchForm.title || undefined,
streamerName: this.searchForm.streamerName || undefined,
streamKey: this.searchForm.streamKey || undefined,
categoryId: this.searchForm.categoryId || undefined,
};
const res = await roomListApi(params);
this.tableData = (res && res.list) || [];
@ -309,21 +406,26 @@ export default {
handleEdit(row) {
this.editForm = {
id: row.id,
status: row.status === 1 ? '正常' : '禁用',
recommendStatus: row.is_recommended === 1 ? '已推荐' : '未推荐',
title: row.title,
categoryId: row.categoryId,
status: row.status || 1,
};
this.editVisible = true;
},
async handleSaveEdit() {
if (!this.editForm.title) {
this.$message.error('请输入直播标题');
return;
}
try {
this.$message.info('当前列表为直播房间数据源,暂不支持在此页面编辑');
await liveRoomUpdateApi(this.editForm);
this.$message.success('保存成功');
this.editVisible = false;
await this.getList();
} catch (error) {
this.$message.error(error.message || '保存失败');
}
},
handleMore(command) {
this.$message.info(`执行${command.type}操作`);
},
handleSizeChange(val) {
this.searchForm.limit = val;
this.getList();
@ -340,7 +442,6 @@ export default {
},
async handleDelete(row) {
try {
// API
this.$message.success('删除成功');
await this.getList();
} catch (error) {
@ -358,7 +459,6 @@ export default {
cancelButtonText: '取消',
type: 'warning'
});
// API
this.$message.success('批量删除成功');
await this.getList();
} catch (error) {
@ -367,30 +467,93 @@ export default {
}
}
},
// ========== ==========
async loadCategories() {
this.categoryLoading = true;
try {
const res = await roomCategoryListApi();
this.categoryList = res || [];
} catch (error) {
console.error('加载分类失败', error);
this.categoryList = [];
} finally {
this.categoryLoading = false;
}
},
openCategoryDialog(row) {
if (row) {
this.categoryForm = { ...row };
} else {
this.categoryForm = {
id: null,
name: '',
icon: '',
sort: 0,
status: 1,
};
}
this.categoryDialogVisible = true;
},
async handleSaveCategory() {
if (!this.categoryForm.name) {
this.$message.error('请输入分类名称');
return;
}
this.categorySaving = true;
try {
await roomCategorySaveApi(this.categoryForm);
this.$message.success('保存成功');
this.categoryDialogVisible = false;
await this.loadCategories();
} catch (error) {
this.$message.error(error.message || '保存失败');
} finally {
this.categorySaving = false;
}
},
async handleDeleteCategory(row) {
try {
await roomCategoryDeleteApi(row.id);
this.$message.success('删除成功');
await this.loadCategories();
} catch (error) {
this.$message.error(error.message || '删除失败');
}
},
async handleCategoryStatusChange(row) {
try {
await roomCategoryUpdateStatusApi(row.id, row.status);
this.$message.success('状态更新成功');
} catch (error) {
this.$message.error(error.message || '状态更新失败');
row.status = row.status === 1 ? 0 : 1;
}
},
},
};
</script>
<style scoped lang="scss">
.selWidth {
width: 200px;
.mt20 {
margin-top: 20px;
}
.mb20 {
margin-bottom: 20px;
}
.search-form-inline {
display: flex;
flex-wrap: nowrap;
flex-wrap: wrap;
align-items: center;
.el-form-item {
margin-right: 10px;
margin-bottom: 0;
margin-bottom: 10px;
}
}
.mt20 {
margin-top: 20px;
}
.chat-header {
display: flex;
justify-content: space-between;
@ -408,10 +571,17 @@ export default {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
gap: 5px;
flex-wrap: wrap;
.el-button {
margin: 0 !important;
margin: 2px !important;
}
}
.icon-tips {
font-size: 12px;
color: #999;
margin-top: 5px;
}
</style>

View File

@ -5,6 +5,7 @@ import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.zbkj.common.model.live.LiveChat;
import com.zbkj.common.model.live.LiveRoom;
import com.zbkj.common.model.live.LiveRoomCategory;
import com.zbkj.common.model.room.Room;
import com.zbkj.common.page.CommonPage;
import com.zbkj.common.request.PageParamRequest;
@ -14,6 +15,7 @@ import com.zbkj.common.result.CommonResult;
import com.zbkj.service.service.RoomService;
import com.zbkj.service.service.LiveRoomService;
import com.zbkj.service.service.LiveChatService;
import com.zbkj.service.service.LiveRoomCategoryService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.Data;
@ -48,6 +50,9 @@ public class RoomController {
@Autowired
private LiveChatService liveChatService;
@Autowired(required = false)
private LiveRoomCategoryService liveRoomCategoryService;
@Value("${LIVE_PUBLIC_SRS_HOST:}")
private String publicHost;
@ -111,7 +116,17 @@ public class RoomController {
public CommonResult<LiveRoomAdminResponse> createLiveRoom(@RequestBody @Validated LiveRoomCreateRequest body,
HttpServletRequest request) {
Integer uid = body.getUid() == null ? 0 : body.getUid();
LiveRoom room = liveRoomService.createRoom(uid, body.getTitle(), body.getStreamerName());
LiveRoom room = liveRoomService.createRoom(
uid,
body.getTitle(),
body.getStreamerName(),
null,
body.getCategoryId(),
null,
null,
null,
null
);
String host = resolveHost(request);
int rtmpPort = parsePort(publicRtmpPort, 25002);
int httpPort = parsePort(publicHttpPort, 25003);
@ -126,6 +141,7 @@ public class RoomController {
if (room == null) return CommonResult.failed("房间不存在");
room.setTitle(body.getTitle());
room.setStreamerName(body.getStreamerName());
room.setCategoryId(body.getCategoryId());
if (!liveRoomService.updateById(room)) {
return CommonResult.failed("保存失败");
}
@ -199,8 +215,17 @@ public class RoomController {
resp.setStreamerName(room.getStreamerName());
resp.setStreamKey(room.getStreamKey());
resp.setIsLive(room.getIsLive() != null && room.getIsLive() == 1);
resp.setCategoryId(room.getCategoryId());
resp.setCreateTime(room.getCreateTime());
// 获取分类名称
if (room.getCategoryId() != null && liveRoomCategoryService != null) {
LiveRoomCategory category = liveRoomCategoryService.getById(room.getCategoryId());
if (category != null) {
resp.setCategoryName(category.getName());
}
}
LiveRoomStreamUrlsResponse urls = new LiveRoomStreamUrlsResponse();
urls.setRtmp(String.format("rtmp://%s:%d/live/%s", host, rtmpPort, room.getStreamKey()));
urls.setFlv(String.format("http://%s:%d/live/%s.flv", host, httpPort, room.getStreamKey()));
@ -248,6 +273,8 @@ public class RoomController {
private String streamerName;
private String streamKey;
private Boolean isLive;
private Integer categoryId;
private String categoryName;
private Date createTime;
private LiveRoomStreamUrlsResponse streamUrls;
}
@ -261,6 +288,8 @@ public class RoomController {
@javax.validation.constraints.NotBlank
private String streamerName;
private Integer categoryId;
}
@Data
@ -273,6 +302,8 @@ public class RoomController {
@javax.validation.constraints.NotBlank
private String streamerName;
private Integer categoryId;
}
@Data
@ -281,4 +312,129 @@ public class RoomController {
private String flv;
private String hls;
}
// ==================== 房间分类管理 ====================
@ApiOperation(value = "分类列表")
@RequestMapping(value = "/category/list", method = RequestMethod.GET)
public CommonResult<List<LiveRoomCategoryResponse>> getCategoryList() {
if (liveRoomCategoryService == null) {
return CommonResult.success(new java.util.ArrayList<LiveRoomCategoryResponse>());
}
List<LiveRoomCategory> list = liveRoomCategoryService.list(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LiveRoomCategory>()
.orderByAsc(LiveRoomCategory::getSort)
.orderByDesc(LiveRoomCategory::getId)
);
List<LiveRoomCategoryResponse> respList = list.stream().map(cat -> {
LiveRoomCategoryResponse resp = new LiveRoomCategoryResponse();
resp.setId(cat.getId());
resp.setName(cat.getName());
resp.setIcon(cat.getIcon());
resp.setSort(cat.getSort());
resp.setStatus(cat.getStatus());
resp.setCreateTime(cat.getCreateTime());
// 统计该分类下的房间数
long count = liveRoomService.count(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LiveRoom>()
.eq(LiveRoom::getCategoryId, cat.getId())
);
resp.setRoomCount((int) count);
return resp;
}).collect(Collectors.toList());
return CommonResult.success(respList);
}
@ApiOperation(value = "保存分类")
@RequestMapping(value = "/category/save", method = RequestMethod.POST)
public CommonResult<String> saveCategory(@RequestBody LiveRoomCategorySaveRequest body) {
if (liveRoomCategoryService == null) {
return CommonResult.failed("分类服务未启用");
}
if (StrUtil.isBlank(body.getName())) {
return CommonResult.failed("分类名称不能为空");
}
LiveRoomCategory category;
if (body.getId() != null && body.getId() > 0) {
category = liveRoomCategoryService.getById(body.getId());
if (category == null) {
return CommonResult.failed("分类不存在");
}
} else {
category = new LiveRoomCategory();
category.setCreateTime(new Date());
}
category.setName(body.getName());
category.setIcon(body.getIcon());
category.setSort(body.getSort() != null ? body.getSort() : 0);
category.setStatus(body.getStatus() != null ? body.getStatus() : 1);
category.setUpdateTime(new Date());
if (body.getId() != null && body.getId() > 0) {
liveRoomCategoryService.updateById(category);
} else {
liveRoomCategoryService.save(category);
}
return CommonResult.success("保存成功");
}
@ApiOperation(value = "删除分类")
@RequestMapping(value = "/category/delete/{id}", method = RequestMethod.POST)
public CommonResult<String> deleteCategory(@PathVariable Integer id) {
if (liveRoomCategoryService == null) {
return CommonResult.failed("分类服务未启用");
}
if (id == null) {
return CommonResult.failed("参数错误");
}
// 检查是否有房间使用该分类
long count = liveRoomService.count(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LiveRoom>()
.eq(LiveRoom::getCategoryId, id)
);
if (count > 0) {
return CommonResult.failed("该分类下有 " + count + " 个房间,无法删除");
}
liveRoomCategoryService.removeById(id);
return CommonResult.success("删除成功");
}
@ApiOperation(value = "更新分类状态")
@RequestMapping(value = "/category/updateStatus", method = RequestMethod.POST)
public CommonResult<String> updateCategoryStatus(@RequestParam Integer id, @RequestParam Integer status) {
if (liveRoomCategoryService == null) {
return CommonResult.failed("分类服务未启用");
}
if (id == null) {
return CommonResult.failed("参数错误");
}
LiveRoomCategory category = liveRoomCategoryService.getById(id);
if (category == null) {
return CommonResult.failed("分类不存在");
}
category.setStatus(status);
category.setUpdateTime(new Date());
liveRoomCategoryService.updateById(category);
return CommonResult.success("更新成功");
}
@Data
public static class LiveRoomCategoryResponse {
private Integer id;
private String name;
private String icon;
private Integer sort;
private Integer status;
private Integer roomCount;
private Date createTime;
}
@Data
public static class LiveRoomCategorySaveRequest {
private Integer id;
private String name;
private String icon;
private Integer sort;
private Integer status;
}
}

View File

@ -0,0 +1,48 @@
package com.zbkj.common.model.live;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.Date;
/**
* 直播间分类
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("eb_live_room_category")
@ApiModel(value = "LiveRoomCategory", description = "直播间分类")
public class LiveRoomCategory implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "分类ID")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty(value = "分类名称")
private String name;
@ApiModelProperty(value = "图标")
private String icon;
@ApiModelProperty(value = "排序")
private Integer sort;
@ApiModelProperty(value = "状态0-禁用1-启用")
private Integer status;
@ApiModelProperty(value = "创建时间")
private Date createTime;
@ApiModelProperty(value = "更新时间")
private Date updateTime;
}

View File

@ -183,10 +183,29 @@ public class FrontTokenComponent {
"api/front/bargain/header",
"api/front/bargain/detail",
"api/front/seckill/header",
"api/front/seckill/detail"
"api/front/seckill/detail",
// 缘池/社区相关接口放行
"api/front/community/categories",
"api/front/community/messages",
"api/front/community/nearby-users",
"api/front/community/user-count",
// 直播间公开接口
"api/front/live/public"
};
return ArrayUtils.contains(routerList, uri);
// 检查精确匹配
if (ArrayUtils.contains(routerList, uri)) {
return true;
}
// 检查前缀匹配支持子路径
for (String route : routerList) {
if (uri.startsWith(route)) {
return true;
}
}
return false;
}
public Boolean check(String token, HttpServletRequest request){

View File

@ -51,10 +51,10 @@ public class LiveRoomController {
@Value("${LIVE_PUBLIC_SRS_HOST:}")
private String publicHost;
@Value("${LIVE_PUBLIC_SRS_RTMP_PORT:}")
@Value("${LIVE_PUBLIC_SRS_RTMP_PORT:1935}")
private String publicRtmpPort;
@Value("${LIVE_PUBLIC_SRS_HTTP_PORT:}")
@Value("${LIVE_PUBLIC_SRS_HTTP_PORT:8080}")
private String publicHttpPort;
@ApiOperation(value = "公开:直播间列表(只返回直播中的房间)")
@ -464,8 +464,8 @@ public class LiveRoomController {
}
String host = (publicHost != null && !publicHost.trim().isEmpty()) ? publicHost.trim() : requestHost;
int rtmpPort = parsePort(publicRtmpPort, 25002);
int httpPort = parsePort(publicHttpPort, 25003);
int rtmpPort = parsePort(publicRtmpPort, 1935);
int httpPort = parsePort(publicHttpPort, 8080);
StreamUrlsResponse urls = new StreamUrlsResponse();
urls.setRtmp(String.format("rtmp://%s:%d/live/%s", host, rtmpPort, room.getStreamKey()));

View File

@ -33,6 +33,7 @@ public class StreamerController {
/**
* 检查当前用户是否是认证主播
* 直接从 eb_user 表读取 is_streamer 字段
*/
@ApiOperation(value = "检查主播资格")
@GetMapping("/check")
@ -42,55 +43,97 @@ public class StreamerController {
return CommonResult.failed("请先登录");
}
log.info("=== 检查主播资格开始 === userId={}", userId);
Map<String, Object> result = new HashMap<>();
result.put("userId", userId);
try {
String sql = "SELECT is_streamer, streamer_level, streamer_intro, streamer_certified_time FROM eb_user WHERE uid = ?";
// 直接查询用户的主播状态
String sql = "SELECT uid, nickname, is_streamer, streamer_level, streamer_intro, streamer_certified_time FROM eb_user WHERE uid = ?";
List<Map<String, Object>> results = jdbcTemplate.queryForList(sql, userId);
Map<String, Object> result = new HashMap<>();
log.info("查询结果: userId={}, 记录数={}", userId, results.size());
if (results.isEmpty()) {
log.warn("用户不存在: userId={}", userId);
result.put("isStreamer", false);
result.put("streamerLevel", 0);
} else {
Map<String, Object> user = results.get(0);
Integer isStreamer = user.get("is_streamer") != null ? ((Number) user.get("is_streamer")).intValue() : 0;
result.put("isStreamer", isStreamer == 1);
result.put("streamerLevel", user.get("streamer_level"));
result.put("streamerIntro", user.get("streamer_intro"));
result.put("certifiedTime", user.get("streamer_certified_time"));
}
// 检查是否有待审核的申请
String pendingSql = "SELECT id, status, create_time FROM eb_streamer_application WHERE user_id = ? ORDER BY create_time DESC LIMIT 1";
List<Map<String, Object>> applications = jdbcTemplate.queryForList(pendingSql, userId);
if (!applications.isEmpty()) {
Map<String, Object> app = applications.get(0);
result.put("hasApplication", true);
result.put("applicationStatus", app.get("status"));
result.put("applicationTime", app.get("create_time"));
} else {
result.put("hasApplication", false);
}
// 检查是否被封禁
String banSql = "SELECT ban_reason, ban_end_time FROM eb_streamer_ban WHERE user_id = ? AND is_active = 1 AND (ban_end_time IS NULL OR ban_end_time > NOW()) LIMIT 1";
List<Map<String, Object>> bans = jdbcTemplate.queryForList(banSql, userId);
if (!bans.isEmpty()) {
result.put("isBanned", true);
result.put("banReason", bans.get(0).get("ban_reason"));
result.put("banEndTime", bans.get(0).get("ban_end_time"));
} else {
result.put("isBanned", false);
return CommonResult.success(result);
}
Map<String, Object> user = results.get(0);
log.info("用户数据: {}", user);
// 读取 is_streamer 字段
Object isStreamerObj = user.get("is_streamer");
boolean isStreamer = false;
int streamerLevel = 0;
if (isStreamerObj != null) {
if (isStreamerObj instanceof Number) {
isStreamer = ((Number) isStreamerObj).intValue() == 1;
} else if (isStreamerObj instanceof Boolean) {
isStreamer = (Boolean) isStreamerObj;
} else {
isStreamer = "1".equals(isStreamerObj.toString());
}
}
Object levelObj = user.get("streamer_level");
if (levelObj != null && levelObj instanceof Number) {
streamerLevel = ((Number) levelObj).intValue();
}
log.info("主播状态解析: userId={}, isStreamer={}, streamerLevel={}", userId, isStreamer, streamerLevel);
result.put("isStreamer", isStreamer);
result.put("streamerLevel", streamerLevel);
result.put("streamerIntro", user.get("streamer_intro"));
result.put("certifiedTime", user.get("streamer_certified_time"));
result.put("nickname", user.get("nickname"));
// 检查是否有待审核的申请可选表可能不存在
result.put("hasApplication", false);
try {
String pendingSql = "SELECT id, status, create_time FROM eb_streamer_application WHERE user_id = ? ORDER BY create_time DESC LIMIT 1";
List<Map<String, Object>> applications = jdbcTemplate.queryForList(pendingSql, userId);
if (!applications.isEmpty()) {
Map<String, Object> app = applications.get(0);
result.put("hasApplication", true);
result.put("applicationStatus", app.get("status"));
result.put("applicationTime", app.get("create_time"));
}
} catch (Exception e) {
log.debug("查询申请记录失败(表可能不存在): {}", e.getMessage());
}
// 检查是否被封禁可选表可能不存在
result.put("isBanned", false);
try {
String banSql = "SELECT ban_reason, ban_end_time FROM eb_streamer_ban WHERE user_id = ? AND is_active = 1 AND (ban_end_time IS NULL OR ban_end_time > NOW()) LIMIT 1";
List<Map<String, Object>> bans = jdbcTemplate.queryForList(banSql, userId);
if (!bans.isEmpty()) {
result.put("isBanned", true);
result.put("banReason", bans.get(0).get("ban_reason"));
result.put("banEndTime", bans.get(0).get("ban_end_time"));
}
} catch (Exception e) {
log.debug("查询封禁记录失败(表可能不存在): {}", e.getMessage());
}
log.info("=== 检查主播资格完成 === userId={}, result={}", userId, result);
return CommonResult.success(result);
} catch (Exception e) {
log.error("检查主播资格失败", e);
// 如果字段不存在返回默认值
Map<String, Object> result = new HashMap<>();
log.error("检查主播资格异常: userId={}", userId, e);
// 出错时返回详细错误信息
result.put("isStreamer", false);
result.put("streamerLevel", 0);
result.put("hasApplication", false);
result.put("isBanned", false);
result.put("error", e.getMessage());
return CommonResult.success(result);
}
}
@ -178,4 +221,137 @@ public class StreamerController {
return CommonResult.failed("获取申请记录失败");
}
}
/**
* 获取主播统计数据
*/
@ApiOperation(value = "获取主播统计数据")
@GetMapping("/stats")
public CommonResult<Map<String, Object>> getStreamerStats() {
Integer userId = frontTokenComponent.getUserId();
if (userId == null) {
return CommonResult.failed("请先登录");
}
try {
Map<String, Object> stats = new HashMap<>();
// 获取用户基本信息
String userSql = "SELECT nickname, avatar, streamer_level FROM eb_user WHERE uid = ?";
List<Map<String, Object>> users = jdbcTemplate.queryForList(userSql, userId);
if (!users.isEmpty()) {
Map<String, Object> user = users.get(0);
stats.put("nickname", user.get("nickname"));
stats.put("avatar", user.get("avatar"));
stats.put("streamerLevel", user.get("streamer_level") != null ? user.get("streamer_level") : 1);
}
// 获取粉丝数
try {
String fansSql = "SELECT COUNT(*) FROM eb_follow_record WHERE follow_user_id = ?";
Integer fansCount = jdbcTemplate.queryForObject(fansSql, Integer.class, userId);
stats.put("fansCount", fansCount != null ? fansCount : 0);
} catch (Exception e) {
stats.put("fansCount", 0);
}
// 获取获赞数从直播间和作品中统计
try {
String likesSql = "SELECT COALESCE(SUM(like_count), 0) FROM eb_live_room WHERE uid = ?";
Integer likesCount = jdbcTemplate.queryForObject(likesSql, Integer.class, userId);
stats.put("likesCount", likesCount != null ? likesCount : 0);
} catch (Exception e) {
stats.put("likesCount", 0);
}
// 获取收到的礼物数
try {
String giftsSql = "SELECT COUNT(*) FROM eb_gift_record WHERE receiver_id = ?";
Integer giftsCount = jdbcTemplate.queryForObject(giftsSql, Integer.class, userId);
stats.put("giftsCount", giftsCount != null ? giftsCount : 0);
} catch (Exception e) {
stats.put("giftsCount", 0);
}
// 获取总收益礼物价值
try {
String incomeSql = "SELECT COALESCE(SUM(g.price * gr.quantity), 0) FROM eb_gift_record gr " +
"JOIN eb_gift g ON gr.gift_id = g.id WHERE gr.receiver_id = ?";
Integer totalIncome = jdbcTemplate.queryForObject(incomeSql, Integer.class, userId);
stats.put("totalIncome", totalIncome != null ? totalIncome : 0);
} catch (Exception e) {
stats.put("totalIncome", 0);
}
// 检查是否有活跃的直播间
try {
String roomSql = "SELECT COUNT(*) FROM eb_live_room WHERE uid = ? AND is_live = 1";
Integer activeRooms = jdbcTemplate.queryForObject(roomSql, Integer.class, userId);
stats.put("hasActiveRoom", activeRooms != null && activeRooms > 0);
} catch (Exception e) {
stats.put("hasActiveRoom", false);
}
return CommonResult.success(stats);
} catch (Exception e) {
log.error("获取主播统计数据失败", e);
return CommonResult.failed("获取统计数据失败");
}
}
/**
* 获取收到的礼物列表
*/
@ApiOperation(value = "获取收到的礼物列表")
@GetMapping("/gifts/received")
public CommonResult<List<Map<String, Object>>> getReceivedGifts(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
Integer userId = frontTokenComponent.getUserId();
if (userId == null) {
return CommonResult.failed("请先登录");
}
try {
int offset = (page - 1) * pageSize;
String sql = "SELECT gr.id, gr.gift_id as giftId, g.name as giftName, g.icon as giftIcon, " +
"g.price as giftPrice, gr.quantity, gr.sender_id as senderId, " +
"u.nickname as senderName, u.avatar as senderAvatar, gr.create_time as createTime " +
"FROM eb_gift_record gr " +
"LEFT JOIN eb_gift g ON gr.gift_id = g.id " +
"LEFT JOIN eb_user u ON gr.sender_id = u.uid " +
"WHERE gr.receiver_id = ? " +
"ORDER BY gr.create_time DESC " +
"LIMIT ? OFFSET ?";
List<Map<String, Object>> gifts = jdbcTemplate.queryForList(sql, userId, pageSize, offset);
return CommonResult.success(gifts);
} catch (Exception e) {
log.error("获取收到的礼物列表失败", e);
return CommonResult.failed("获取礼物列表失败");
}
}
/**
* 获取我的直播间列表
*/
@ApiOperation(value = "获取我的直播间列表")
@GetMapping("/rooms")
public CommonResult<List<Map<String, Object>>> getMyRooms() {
Integer userId = frontTokenComponent.getUserId();
if (userId == null) {
return CommonResult.failed("请先登录");
}
try {
String sql = "SELECT id, title, stream_key as streamKey, is_live as isLive, " +
"view_count as viewCount, like_count as likeCount, online_count as onlineCount, " +
"create_time as createTime, started_at as startedAt " +
"FROM eb_live_room WHERE uid = ? ORDER BY create_time DESC";
List<Map<String, Object>> rooms = jdbcTemplate.queryForList(sql, userId);
return CommonResult.success(rooms);
} catch (Exception e) {
log.error("获取我的直播间列表失败", e);
return CommonResult.failed("获取直播间列表失败");
}
}
}

View File

@ -0,0 +1,10 @@
package com.zbkj.service.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zbkj.common.model.live.LiveRoomCategory;
/**
* 直播间分类 Mapper
*/
public interface LiveRoomCategoryDao extends BaseMapper<LiveRoomCategory> {
}

View File

@ -0,0 +1,17 @@
package com.zbkj.service.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zbkj.common.model.live.LiveRoomCategory;
import java.util.List;
/**
* 直播间分类 Service
*/
public interface LiveRoomCategoryService extends IService<LiveRoomCategory> {
/**
* 获取启用的分类列表
*/
List<LiveRoomCategory> getEnabledList();
}

View File

@ -0,0 +1,26 @@
package com.zbkj.service.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zbkj.common.model.live.LiveRoomCategory;
import com.zbkj.service.dao.LiveRoomCategoryDao;
import com.zbkj.service.service.LiveRoomCategoryService;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 直播间分类 Service 实现
*/
@Service
public class LiveRoomCategoryServiceImpl extends ServiceImpl<LiveRoomCategoryDao, LiveRoomCategory> implements LiveRoomCategoryService {
@Override
public List<LiveRoomCategory> getEnabledList() {
LambdaQueryWrapper<LiveRoomCategory> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LiveRoomCategory::getStatus, 1);
wrapper.orderByAsc(LiveRoomCategory::getSort);
wrapper.orderByDesc(LiveRoomCategory::getId);
return list(wrapper);
}
}

View File

@ -126,4 +126,7 @@ dependencies {
// WebRTC for voice/video calls
// 使用 Google 官方 WebRTC 库
implementation("io.getstream:stream-webrtc-android:1.1.1")
// RTMP 推流 SDK手机开播- RootEncoder 2.2.2
implementation("com.github.pedroSG94.rtmp-rtsp-stream-client-java:rtplibrary:2.2.2")
}

View File

@ -249,6 +249,19 @@
android:exported="false"
android:screenOrientation="portrait" />
<!-- 主播中心Activity -->
<activity
android:name="com.example.livestreaming.StreamerCenterActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- 手机开播Activity -->
<activity
android:name="com.example.livestreaming.BroadcastActivity"
android:exported="false"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize" />
</application>
</manifest>

View File

@ -0,0 +1,777 @@
package com.example.livestreaming;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import android.util.Size;
import android.view.SurfaceHolder;
import android.view.View;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import com.example.livestreaming.databinding.ActivityBroadcastBinding;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.AuthStore;
import com.example.livestreaming.net.CreateRoomRequest;
import com.example.livestreaming.net.Room;
import com.pedro.encoder.input.video.CameraHelper;
import com.pedro.rtmp.utils.ConnectCheckerRtmp;
import com.pedro.rtplibrary.rtmp.RtmpCamera1;
import com.pedro.rtplibrary.rtmp.RtmpCamera2;
import java.util.Locale;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 手机开播界面
* 使用 RootEncoder 进行 RTMP 推流
* 优先使用 Camera2 API (RtmpCamera2)兼容性更好
*/
public class BroadcastActivity extends AppCompatActivity implements ConnectCheckerRtmp, SurfaceHolder.Callback {
private static final String TAG = "BroadcastActivity";
private static final int REQUEST_PERMISSIONS = 100;
private static final String[] REQUIRED_PERMISSIONS = {
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
};
private ActivityBroadcastBinding binding;
// 使用 Camera2 API 的推流器
private RtmpCamera2 rtmpCamera2;
// 备用使用 Camera1 API 的推流器
private RtmpCamera1 rtmpCamera1;
private boolean useCamera2 = true;
private Room currentRoom;
private boolean isStreaming = false;
private boolean isFrontCamera = true;
private boolean surfaceReady = false;
private boolean streamerVerified = false;
private boolean cameraInitialized = false;
// 推流参数
private static final int VIDEO_WIDTH = 640;
private static final int VIDEO_HEIGHT = 480;
private static final int VIDEO_FPS = 25;
private static final int VIDEO_BITRATE = 1500 * 1024; // 1.5Mbps
private static final int AUDIO_BITRATE = 64 * 1024;
private static final int AUDIO_SAMPLE_RATE = 32000;
// 直播计时
private Handler timerHandler = new Handler(Looper.getMainLooper());
private Handler mainHandler = new Handler(Looper.getMainLooper());
private long startTime = 0;
private Runnable timerRunnable;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 保持屏幕常亮
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
binding = ActivityBroadcastBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
// 检查登录状态
if (!AuthHelper.isLoggedIn(this)) {
Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show();
finish();
return;
}
setupUI();
setupSurface();
// 先检查主播资格通过后再检查权限
checkStreamerStatus();
}
/**
* 检查主播资格
*/
private void checkStreamerStatus() {
binding.progressBar.setVisibility(View.VISIBLE);
binding.btnStartLive.setEnabled(false);
ApiClient.getService(this).checkStreamerStatus()
.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
Response<ApiResponse<Map<String, Object>>> response) {
binding.progressBar.setVisibility(View.GONE);
if (!response.isSuccessful() || response.body() == null) {
// 接口调用失败可能是旧版本后端允许继续
Log.w(TAG, "检查主播资格接口失败,允许继续");
streamerVerified = true;
binding.btnStartLive.setEnabled(true);
checkPermissions();
return;
}
ApiResponse<Map<String, Object>> body = response.body();
if (body.getCode() != 200 || body.getData() == null) {
// 接口返回错误可能是旧版本后端允许继续
Log.w(TAG, "检查主播资格返回错误,允许继续");
streamerVerified = true;
binding.btnStartLive.setEnabled(true);
checkPermissions();
return;
}
Map<String, Object> data = body.getData();
Boolean isStreamer = data.get("isStreamer") != null && (Boolean) data.get("isStreamer");
Boolean isBanned = data.get("isBanned") != null && (Boolean) data.get("isBanned");
Boolean hasApplication = data.get("hasApplication") != null && (Boolean) data.get("hasApplication");
Object appStatusObj = data.get("applicationStatus");
Integer applicationStatus = appStatusObj != null ? ((Number) appStatusObj).intValue() : null;
if (isBanned) {
// 被封禁
String banReason = (String) data.get("banReason");
showBlockedDialog("您的主播资格已被封禁" + (banReason != null ? "" + banReason : ""));
return;
}
if (!isStreamer) {
// 不是主播
if (hasApplication && applicationStatus != null && applicationStatus == 0) {
// 有待审核的申请
showBlockedDialog("您的主播认证申请正在审核中,请耐心等待");
} else {
// 没有申请或申请被拒绝提示申请认证
showApplyStreamerDialog();
}
return;
}
// 是认证主播可以开播
streamerVerified = true;
binding.btnStartLive.setEnabled(true);
checkPermissions();
}
@Override
public void onFailure(Call<ApiResponse<Map<String, Object>>> call, Throwable t) {
binding.progressBar.setVisibility(View.GONE);
// 网络错误可能是旧版本后端允许继续
Log.w(TAG, "检查主播资格网络错误,允许继续", t);
streamerVerified = true;
binding.btnStartLive.setEnabled(true);
checkPermissions();
}
});
}
/**
* 显示被阻止的对话框
*/
private void showBlockedDialog(String message) {
new AlertDialog.Builder(this)
.setTitle("无法开播")
.setMessage(message)
.setPositiveButton("确定", (dialog, which) -> finish())
.setCancelable(false)
.show();
}
/**
* 显示申请主播认证的对话框
*/
private void showApplyStreamerDialog() {
new AlertDialog.Builder(this)
.setTitle("需要主播认证")
.setMessage("只有认证主播才能开播,是否现在申请主播认证?")
.setPositiveButton("去申请", (dialog, which) -> {
// 跳转到主播认证申请页面
Intent intent = new Intent(this, StreamerApplyActivity.class);
startActivity(intent);
finish();
})
.setNegativeButton("取消", (dialog, which) -> finish())
.setCancelable(false)
.show();
}
private void setupUI() {
// 关闭按钮
binding.btnClose.setOnClickListener(v -> {
if (isStreaming) {
showStopConfirmDialog();
} else {
finish();
}
});
// 切换摄像头
binding.btnSwitchCamera.setOnClickListener(v -> switchCamera());
// 设置按钮
binding.btnSettings.setOnClickListener(v -> {
Toast.makeText(this, "设置功能开发中", Toast.LENGTH_SHORT).show();
});
// 开始直播
binding.btnStartLive.setOnClickListener(v -> startLive());
// 停止直播
binding.btnStopLive.setOnClickListener(v -> showStopConfirmDialog());
}
private void setupSurface() {
binding.surfaceView.getHolder().addCallback(this);
}
private void checkPermissions() {
boolean allGranted = true;
for (String permission : REQUIRED_PERMISSIONS) {
if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
allGranted = false;
break;
}
}
if (allGranted) {
initCamera();
} else {
ActivityCompat.requestPermissions(this, REQUIRED_PERMISSIONS, REQUEST_PERMISSIONS);
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_PERMISSIONS) {
boolean allGranted = true;
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
allGranted = false;
break;
}
}
if (allGranted) {
initCamera();
} else {
Toast.makeText(this, "需要摄像头和麦克风权限才能开播", Toast.LENGTH_LONG).show();
finish();
}
}
}
private void initCamera() {
if (!surfaceReady) {
Log.d(TAG, "Surface 未就绪,等待...");
return;
}
if (cameraInitialized) {
Log.d(TAG, "摄像头已初始化");
return;
}
// 检查是否有摄像头
if (!hasCamera()) {
Log.e(TAG, "设备没有摄像头");
Toast.makeText(this, "设备没有摄像头,无法开播", Toast.LENGTH_LONG).show();
return;
}
Log.d(TAG, "开始初始化摄像头...");
// 延迟初始化确保 Surface 完全准备好
mainHandler.postDelayed(() -> {
try {
initCameraInternal();
} catch (Exception e) {
Log.e(TAG, "摄像头初始化异常: " + e.getMessage(), e);
Toast.makeText(this, "摄像头初始化失败", Toast.LENGTH_LONG).show();
}
}, 500);
}
private void initCameraInternal() {
// 优先尝试 Camera2 API (Android 5.0+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
try {
Log.d(TAG, "尝试使用 Camera2 API...");
rtmpCamera2 = new RtmpCamera2(binding.surfaceView, this);
// 准备编码器
boolean audioReady = rtmpCamera2.prepareAudio(AUDIO_BITRATE, AUDIO_SAMPLE_RATE, false);
boolean videoReady = rtmpCamera2.prepareVideo(VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS, VIDEO_BITRATE, 0);
Log.d(TAG, "Camera2 编码器准备: audio=" + audioReady + ", video=" + videoReady);
if (videoReady) {
// 开始预览
String cameraId = isFrontCamera ? "1" : "0";
rtmpCamera2.startPreview(cameraId);
useCamera2 = true;
cameraInitialized = true;
Log.d(TAG, "Camera2 预览已开始");
return;
}
} catch (Exception e) {
Log.w(TAG, "Camera2 初始化失败: " + e.getMessage());
rtmpCamera2 = null;
}
}
// 回退到 Camera1 API
try {
Log.d(TAG, "尝试使用 Camera1 API...");
rtmpCamera1 = new RtmpCamera1(binding.surfaceView, this);
boolean audioReady = rtmpCamera1.prepareAudio();
boolean videoReady = rtmpCamera1.prepareVideo();
Log.d(TAG, "Camera1 编码器准备: audio=" + audioReady + ", video=" + videoReady);
if (videoReady) {
CameraHelper.Facing facing = isFrontCamera ? CameraHelper.Facing.FRONT : CameraHelper.Facing.BACK;
rtmpCamera1.startPreview(facing);
useCamera2 = false;
cameraInitialized = true;
Log.d(TAG, "Camera1 预览已开始");
return;
}
} catch (Exception e) {
Log.e(TAG, "Camera1 初始化也失败: " + e.getMessage());
rtmpCamera1 = null;
}
Toast.makeText(this, "摄像头初始化失败,请检查权限或重启应用", Toast.LENGTH_LONG).show();
}
/**
* 检查设备是否有摄像头
*/
private boolean hasCamera() {
return getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
}
private void switchCamera() {
isFrontCamera = !isFrontCamera;
if (useCamera2 && rtmpCamera2 != null) {
try {
String cameraId = isFrontCamera ? "1" : "0";
rtmpCamera2.switchCamera();
Toast.makeText(this, isFrontCamera ? "前置摄像头" : "后置摄像头", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
Log.e(TAG, "切换摄像头失败: " + e.getMessage());
}
} else if (rtmpCamera1 != null) {
try {
rtmpCamera1.switchCamera();
Toast.makeText(this, isFrontCamera ? "前置摄像头" : "后置摄像头", Toast.LENGTH_SHORT).show();
} catch (Exception e) {
Log.e(TAG, "切换摄像头失败: " + e.getMessage());
}
}
}
private void startLive() {
// 检查主播资格是否已验证
if (!streamerVerified) {
Toast.makeText(this, "正在验证主播资格...", Toast.LENGTH_SHORT).show();
checkStreamerStatus();
return;
}
String title = binding.etTitle.getText() != null ?
binding.etTitle.getText().toString().trim() : "";
if (TextUtils.isEmpty(title)) {
Toast.makeText(this, "请输入直播标题", Toast.LENGTH_SHORT).show();
return;
}
if (!cameraInitialized) {
Toast.makeText(this, "摄像头未初始化,请稍候", Toast.LENGTH_SHORT).show();
return;
}
binding.progressBar.setVisibility(View.VISIBLE);
binding.btnStartLive.setEnabled(false);
// 先创建直播间
createRoom(title);
}
private void createRoom(String title) {
String nickname = AuthStore.getNickname(this);
if (TextUtils.isEmpty(nickname)) {
nickname = "主播";
}
CreateRoomRequest request = new CreateRoomRequest();
request.setTitle(title);
request.setStreamerName(nickname);
ApiClient.getService(this).createRoom(request).enqueue(new Callback<ApiResponse<Room>>() {
@Override
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) {
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
currentRoom = response.body().getData();
Log.d(TAG, "直播间创建成功: " + currentRoom.getId());
startStreaming();
} else {
binding.progressBar.setVisibility(View.GONE);
binding.btnStartLive.setEnabled(true);
String msg = response.body() != null ? response.body().getMessage() : "创建直播间失败";
Toast.makeText(BroadcastActivity.this, msg, Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<Room>> call, Throwable t) {
binding.progressBar.setVisibility(View.GONE);
binding.btnStartLive.setEnabled(true);
Toast.makeText(BroadcastActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
private void startStreaming() {
if (currentRoom == null) {
Log.e(TAG, "currentRoom 为空");
binding.progressBar.setVisibility(View.GONE);
binding.btnStartLive.setEnabled(true);
Toast.makeText(this, "获取房间信息失败", Toast.LENGTH_SHORT).show();
return;
}
if (currentRoom.getStreamUrls() == null) {
Log.e(TAG, "streamUrls 为空, roomId=" + currentRoom.getId() + ", streamKey=" + currentRoom.getStreamKey());
binding.progressBar.setVisibility(View.GONE);
binding.btnStartLive.setEnabled(true);
Toast.makeText(this, "获取推流地址失败", Toast.LENGTH_SHORT).show();
return;
}
String rtmpUrl = currentRoom.getStreamUrls().getRtmp();
String flvUrl = currentRoom.getStreamUrls().getFlv();
String hlsUrl = currentRoom.getStreamUrls().getHls();
Log.d(TAG, "========== 流地址信息 ==========");
Log.d(TAG, "房间ID: " + currentRoom.getId());
Log.d(TAG, "StreamKey: " + currentRoom.getStreamKey());
Log.d(TAG, "RTMP推流地址: " + rtmpUrl);
Log.d(TAG, "FLV播放地址: " + flvUrl);
Log.d(TAG, "HLS播放地址: " + hlsUrl);
Log.d(TAG, "================================");
if (TextUtils.isEmpty(rtmpUrl)) {
Log.e(TAG, "RTMP推流地址为空");
binding.progressBar.setVisibility(View.GONE);
binding.btnStartLive.setEnabled(true);
Toast.makeText(this, "推流地址无效", Toast.LENGTH_SHORT).show();
return;
}
Log.d(TAG, "开始推流到: " + rtmpUrl);
try {
if (useCamera2 && rtmpCamera2 != null && !rtmpCamera2.isStreaming()) {
Log.d(TAG, "使用 Camera2 API 推流");
rtmpCamera2.startStream(rtmpUrl);
} else if (rtmpCamera1 != null && !rtmpCamera1.isStreaming()) {
Log.d(TAG, "使用 Camera1 API 推流");
rtmpCamera1.startStream(rtmpUrl);
} else {
Log.e(TAG, "没有可用的摄像头推流器");
binding.progressBar.setVisibility(View.GONE);
binding.btnStartLive.setEnabled(true);
Toast.makeText(this, "摄像头未初始化", Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "推流失败: " + e.getMessage(), e);
binding.progressBar.setVisibility(View.GONE);
binding.btnStartLive.setEnabled(true);
Toast.makeText(this, "推流失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
private void stopStreaming() {
try {
if (useCamera2 && rtmpCamera2 != null && rtmpCamera2.isStreaming()) {
rtmpCamera2.stopStream();
} else if (rtmpCamera1 != null && rtmpCamera1.isStreaming()) {
rtmpCamera1.stopStream();
}
} catch (Exception e) {
Log.e(TAG, "停止推流失败: " + e.getMessage());
}
isStreaming = false;
stopTimer();
updateUI(false);
// 删除直播间
if (currentRoom != null) {
ApiClient.getService(this).deleteRoom(currentRoom.getId()).enqueue(new Callback<ApiResponse<Object>>() {
@Override
public void onResponse(Call<ApiResponse<Object>> call, Response<ApiResponse<Object>> response) {
Log.d(TAG, "直播间已删除");
}
@Override
public void onFailure(Call<ApiResponse<Object>> call, Throwable t) {
Log.e(TAG, "删除直播间失败: " + t.getMessage());
}
});
currentRoom = null;
}
}
private void showStopConfirmDialog() {
new com.google.android.material.dialog.MaterialAlertDialogBuilder(this)
.setTitle("结束直播")
.setMessage("确定要结束直播吗?")
.setPositiveButton("结束", (dialog, which) -> {
stopStreaming();
finish();
})
.setNegativeButton("取消", null)
.show();
}
private void updateUI(boolean streaming) {
if (streaming) {
binding.cardLiveInfo.setVisibility(View.GONE);
binding.btnStartLive.setVisibility(View.GONE);
binding.btnStopLive.setVisibility(View.VISIBLE);
binding.liveStatusBar.setVisibility(View.VISIBLE);
} else {
binding.cardLiveInfo.setVisibility(View.VISIBLE);
binding.btnStartLive.setVisibility(View.VISIBLE);
binding.btnStopLive.setVisibility(View.GONE);
binding.liveStatusBar.setVisibility(View.GONE);
}
binding.progressBar.setVisibility(View.GONE);
binding.btnStartLive.setEnabled(true);
}
private void startTimer() {
startTime = System.currentTimeMillis();
timerRunnable = new Runnable() {
@Override
public void run() {
long elapsed = System.currentTimeMillis() - startTime;
int seconds = (int) (elapsed / 1000) % 60;
int minutes = (int) (elapsed / 1000 / 60) % 60;
int hours = (int) (elapsed / 1000 / 60 / 60);
binding.tvLiveTime.setText(String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds));
timerHandler.postDelayed(this, 1000);
}
};
timerHandler.post(timerRunnable);
}
private void stopTimer() {
if (timerRunnable != null) {
timerHandler.removeCallbacks(timerRunnable);
timerRunnable = null;
}
}
// ========== SurfaceHolder.Callback ==========
@Override
public void surfaceCreated(@NonNull SurfaceHolder holder) {
Log.d(TAG, "Surface created");
surfaceReady = true;
initCamera();
}
@Override
public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, int height) {
Log.d(TAG, "Surface changed: " + width + "x" + height);
}
@Override
public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
Log.d(TAG, "Surface destroyed");
surfaceReady = false;
try {
if (rtmpCamera2 != null) {
if (rtmpCamera2.isStreaming()) {
rtmpCamera2.stopStream();
}
if (rtmpCamera2.isOnPreview()) {
rtmpCamera2.stopPreview();
}
}
if (rtmpCamera1 != null) {
if (rtmpCamera1.isStreaming()) {
rtmpCamera1.stopStream();
}
if (rtmpCamera1.isOnPreview()) {
rtmpCamera1.stopPreview();
}
}
} catch (Exception e) {
Log.e(TAG, "停止预览失败: " + e.getMessage());
}
}
// ========== ConnectCheckerRtmp 回调 ==========
@Override
public void onConnectionStartedRtmp(String rtmpUrl) {
Log.d(TAG, "========== RTMP连接开始 ==========");
Log.d(TAG, "正在连接: " + rtmpUrl);
}
@Override
public void onConnectionSuccessRtmp() {
runOnUiThread(() -> {
Log.d(TAG, "========== RTMP连接成功 ==========");
Log.d(TAG, "推流已成功连接到服务器");
isStreaming = true;
updateUI(true);
startTimer();
Toast.makeText(this, "直播已开始", Toast.LENGTH_SHORT).show();
});
}
@Override
public void onConnectionFailedRtmp(String reason) {
runOnUiThread(() -> {
Log.e(TAG, "========== RTMP连接失败 ==========");
Log.e(TAG, "失败原因: " + reason);
Log.e(TAG, "请检查:");
Log.e(TAG, "1. SRS服务器是否运行");
Log.e(TAG, "2. RTMP端口(1935/25002)是否开放");
Log.e(TAG, "3. 手机网络是否能访问服务器");
isStreaming = false;
updateUI(false);
Toast.makeText(this, "连接失败: " + reason, Toast.LENGTH_LONG).show();
});
}
@Override
public void onNewBitrateRtmp(long bitrate) {
// 码率变化回调可用于显示当前上传速度
Log.v(TAG, "当前码率: " + (bitrate / 1024) + " kbps");
}
@Override
public void onDisconnectRtmp() {
runOnUiThread(() -> {
Log.d(TAG, "推流断开");
if (isStreaming) {
isStreaming = false;
updateUI(false);
Toast.makeText(this, "直播已断开", Toast.LENGTH_SHORT).show();
}
});
}
@Override
public void onAuthErrorRtmp() {
runOnUiThread(() -> {
Log.e(TAG, "推流认证失败");
Toast.makeText(this, "推流认证失败", Toast.LENGTH_SHORT).show();
});
}
@Override
public void onAuthSuccessRtmp() {
Log.d(TAG, "推流认证成功");
}
@Override
protected void onResume() {
super.onResume();
if (cameraInitialized && surfaceReady && !isStreaming) {
try {
if (useCamera2 && rtmpCamera2 != null && !rtmpCamera2.isOnPreview()) {
String cameraId = isFrontCamera ? "1" : "0";
rtmpCamera2.startPreview(cameraId);
} else if (rtmpCamera1 != null && !rtmpCamera1.isOnPreview()) {
CameraHelper.Facing facing = isFrontCamera ? CameraHelper.Facing.FRONT : CameraHelper.Facing.BACK;
rtmpCamera1.startPreview(facing);
}
} catch (Exception e) {
Log.e(TAG, "恢复预览失败: " + e.getMessage());
}
}
}
@Override
protected void onPause() {
super.onPause();
// 如果正在直播不停止预览
if (!isStreaming) {
try {
if (rtmpCamera2 != null && rtmpCamera2.isOnPreview()) {
rtmpCamera2.stopPreview();
}
if (rtmpCamera1 != null && rtmpCamera1.isOnPreview()) {
rtmpCamera1.stopPreview();
}
} catch (Exception e) {
Log.e(TAG, "暂停预览失败: " + e.getMessage());
}
}
}
@Override
protected void onDestroy() {
super.onDestroy();
stopTimer();
try {
if (rtmpCamera2 != null) {
if (rtmpCamera2.isStreaming()) {
rtmpCamera2.stopStream();
}
if (rtmpCamera2.isOnPreview()) {
rtmpCamera2.stopPreview();
}
}
if (rtmpCamera1 != null) {
if (rtmpCamera1.isStreaming()) {
rtmpCamera1.stopStream();
}
if (rtmpCamera1.isOnPreview()) {
rtmpCamera1.stopPreview();
}
}
} catch (Exception e) {
Log.e(TAG, "销毁时清理失败: " + e.getMessage());
}
}
@Override
public void onBackPressed() {
if (isStreaming) {
showStopConfirmDialog();
} else {
super.onBackPressed();
}
}
}

View File

@ -162,6 +162,9 @@ public class LoginActivity extends AppCompatActivity {
}
}
// 检查主播状态
checkStreamerStatus();
// 登录成功
Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show();
@ -193,5 +196,31 @@ public class LoginActivity extends AppCompatActivity {
});
}
}
/**
* 检查用户是否是认证主播
*/
private void checkStreamerStatus() {
ApiClient.getService(getApplicationContext()).checkStreamerStatus()
.enqueue(new Callback<ApiResponse<java.util.Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<java.util.Map<String, Object>>> call,
Response<ApiResponse<java.util.Map<String, Object>>> response) {
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
java.util.Map<String, Object> data = response.body().getData();
if (data != null) {
Object isStreamerObj = data.get("isStreamer");
boolean isStreamer = isStreamerObj != null && (Boolean) isStreamerObj;
AuthStore.setIsStreamer(getApplicationContext(), isStreamer);
android.util.Log.d("LoginActivity", "主播状态: " + isStreamer);
}
}
}
@Override
public void onFailure(Call<ApiResponse<java.util.Map<String, Object>>> call, Throwable t) {
android.util.Log.e("LoginActivity", "检查主播状态失败", t);
}
});
}
}

View File

@ -185,8 +185,9 @@ public class MainActivity extends AppCompatActivity {
items.add(new DrawerCardItem(DrawerCardItem.ACTION_GROUPS, "我的群组", "群聊与群组管理", R.drawable.ic_group_24));
items.add(new DrawerCardItem(DrawerCardItem.ACTION_FISH_POND, "缘池", "附近与社交圈", R.drawable.ic_people_24));
items.add(new DrawerCardItem(DrawerCardItem.ACTION_FOLLOWING, "我的关注", "你关注的主播", R.drawable.ic_people_24));
items.add(new DrawerCardItem(DrawerCardItem.ACTION_FANS, "粉丝", "关注你的人", R.drawable.ic_people_24));
items.add(new DrawerCardItem(DrawerCardItem.ACTION_LIKES, "获赞", "收到的点赞", R.drawable.ic_heart_24));
// 隐藏粉丝和获赞菜单项
// items.add(new DrawerCardItem(DrawerCardItem.ACTION_FANS, "粉丝", "关注你的人", R.drawable.ic_people_24));
// items.add(new DrawerCardItem(DrawerCardItem.ACTION_LIKES, "获赞", "收到的点赞", R.drawable.ic_heart_24));
items.add(new DrawerCardItem(DrawerCardItem.ACTION_HISTORY, "观看历史", "最近看过的直播", R.drawable.ic_grid_24));
items.add(new DrawerCardItem(DrawerCardItem.ACTION_SEARCH, "搜索", "找主播/房间/标签", R.drawable.ic_search_24));
items.add(new DrawerCardItem(DrawerCardItem.ACTION_SETTINGS, "设置", "账号、隐私、通知", R.drawable.ic_menu_24));
@ -854,6 +855,66 @@ public class MainActivity extends AppCompatActivity {
// 确保通话信令 WebSocket 保持连接用于接收来电通知
LiveStreamingApplication app = (LiveStreamingApplication) getApplication();
app.connectCallSignalingIfLoggedIn();
// 检查主播状态并显示/隐藏开播按钮
checkAndUpdateStreamerButton();
}
/**
* 检查主播状态并更新开播按钮的显示
*/
private void checkAndUpdateStreamerButton() {
// 如果用户未登录隐藏开播按钮
if (!AuthHelper.isLoggedIn(this)) {
if (binding.fabAddLive != null) {
binding.fabAddLive.setVisibility(View.GONE);
}
return;
}
// 检查主播资格
ApiClient.getService(getApplicationContext()).checkStreamerStatus()
.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
Response<ApiResponse<Map<String, Object>>> response) {
if (!response.isSuccessful() || response.body() == null) {
// 接口调用失败隐藏按钮
if (binding.fabAddLive != null) {
binding.fabAddLive.setVisibility(View.GONE);
}
return;
}
ApiResponse<Map<String, Object>> body = response.body();
if (body.getCode() != 200 || body.getData() == null) {
// 接口返回错误隐藏按钮
if (binding.fabAddLive != null) {
binding.fabAddLive.setVisibility(View.GONE);
}
return;
}
Map<String, Object> data = body.getData();
Boolean isStreamer = data.get("isStreamer") != null && (Boolean) data.get("isStreamer");
Boolean isBanned = data.get("isBanned") != null && (Boolean) data.get("isBanned");
// 只有认证主播且未被封禁才显示开播按钮
if (isStreamer && !isBanned && binding.fabAddLive != null) {
binding.fabAddLive.setVisibility(View.VISIBLE);
} else if (binding.fabAddLive != null) {
binding.fabAddLive.setVisibility(View.GONE);
}
}
@Override
public void onFailure(Call<ApiResponse<Map<String, Object>>> call, Throwable t) {
// 网络错误隐藏按钮
if (binding.fabAddLive != null) {
binding.fabAddLive.setVisibility(View.GONE);
}
}
});
}
/**
@ -1033,6 +1094,24 @@ public class MainActivity extends AppCompatActivity {
}
private void showCreateRoomDialogInternal() {
// 显示选择对话框手机开播 OBS推流
new AlertDialog.Builder(this)
.setTitle("选择开播方式")
.setItems(new String[]{"📱 手机开播", "💻 OBS推流"}, (dialog, which) -> {
if (which == 0) {
// 手机开播 - 跳转到 BroadcastActivity
Intent intent = new Intent(MainActivity.this, BroadcastActivity.class);
startActivity(intent);
} else {
// OBS推流 - 显示创建直播间对话框
showOBSCreateRoomDialog();
}
})
.setNegativeButton("取消", null)
.show();
}
private void showOBSCreateRoomDialog() {
View dialogView = getLayoutInflater().inflate(R.layout.dialog_create_room, null);
DialogCreateRoomBinding dialogBinding = DialogCreateRoomBinding.bind(dialogView);

View File

@ -1,5 +1,6 @@
package com.example.livestreaming;
import android.graphics.SurfaceTexture;
import android.os.Bundle;
import android.view.Surface;
import android.view.TextureView;
@ -78,14 +79,14 @@ public class PlayerActivity extends AppCompatActivity {
releaseExoPlayer();
triedAltUrl = false;
// 优化缓冲配置平衡延迟和流畅度
// 优化缓冲配置 - 针对低延迟 HLS1秒分片
androidx.media3.exoplayer.DefaultLoadControl loadControl =
new androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setBufferDurationsMs(
3000, // 最小缓冲 3
15000, // 最大缓冲 15
2500, // 最小缓冲 2.5
10000, // 最大缓冲 10
1500, // 播放前缓冲 1.5秒
3000 // 重新缓冲 3
2500 // 重新缓冲 2.5
)
.setPrioritizeTimeOverSizeThresholds(true)
.build();
@ -142,33 +143,137 @@ public class PlayerActivity extends AppCompatActivity {
}
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
// 禁用 IjkPlayer直接使用 HLS 播放IjkPlayer 在某些设备上会崩溃
android.util.Log.d("PlayerActivity", "FLV流转为HLS播放: " + flvUrl);
android.util.Log.d("PlayerActivity", "使用IJK播放FLV流: " + flvUrl);
// FLV 地址转换为 HLS 地址
String hlsUrl = fallbackHlsUrl;
if (hlsUrl == null || hlsUrl.trim().isEmpty()) {
hlsUrl = flvUrl.replace(".flv", ".m3u8");
// 释放 ExoPlayer
releaseExoPlayer();
releaseIjkPlayer();
// 确保 IJK 库已加载
ensureIjkLibsLoaded();
// 保存回退地址
ijkUrl = flvUrl;
ijkFallbackHlsUrl = fallbackHlsUrl;
if (ijkFallbackHlsUrl == null || ijkFallbackHlsUrl.trim().isEmpty()) {
ijkFallbackHlsUrl = flvUrl.replace(".flv", ".m3u8");
}
ijkFallbackTried = false;
startHls(hlsUrl, null);
// 显示 TextureView隐藏 ExoPlayer PlayerView
if (binding != null) {
binding.playerView.setVisibility(android.view.View.GONE);
binding.flvTextureView.setVisibility(android.view.View.VISIBLE);
// 设置 TextureView 监听器
binding.flvTextureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
@Override
public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture surface, int width, int height) {
android.util.Log.d("PlayerActivity", "SurfaceTexture可用准备IJK播放器");
ijkSurface = new Surface(surface);
prepareIjk(flvUrl);
}
@Override
public void onSurfaceTextureSizeChanged(android.graphics.SurfaceTexture surface, int width, int height) {
// 尺寸变化时不需要处理
}
@Override
public boolean onSurfaceTextureDestroyed(android.graphics.SurfaceTexture surface) {
android.util.Log.d("PlayerActivity", "SurfaceTexture销毁");
releaseIjkPlayer();
return true;
}
@Override
public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture surface) {
// 更新时不需要处理
}
});
// 如果 TextureView 已经可用直接准备播放
if (binding.flvTextureView.isAvailable()) {
android.util.Log.d("PlayerActivity", "TextureView已可用直接准备IJK播放器");
ijkSurface = new Surface(binding.flvTextureView.getSurfaceTexture());
prepareIjk(flvUrl);
}
}
}
private void prepareIjk(String url) {
if (ijkSurface == null) return;
IjkMediaPlayer p = new IjkMediaPlayer();
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", 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 -> mp.start());
// ========== 超低延迟核心配置 ==========
// 禁用数据包缓冲直接播放
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 0);
// 准备好立即播放
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1);
// 无限缓冲模式配合max_cached_duration使用
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1);
// 最大缓存时长 200ms极低延迟
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 200);
// 最小帧数 2快速启动
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "min-frames", 2);
// 允许丢帧追赶延迟
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 5);
// 同步类型视频为主
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "sync-av-start", 0);
// ========== 格式/解复用配置 ==========
// 禁用缓冲
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer");
// 分析时长 100us极短
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 100);
// 探测大小 1KB极小
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 1024);
// 刷新数据包
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "flush_packets", 1);
// 禁用DNS缓存
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "dns_cache_clear", 1);
// ========== 编解码配置 ==========
// 使用硬件解码如果可用
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-auto-rotate", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-handle-resolution-change", 1);
// 跳过循环滤波加速解码
p.setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, "skip_loop_filter", 48);
// 跳过帧加速解码
p.setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, "skip_frame", 0);
// ========== 网络配置 ==========
// 重连次数
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect", 1);
// 超时时间 3秒
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "timeout", 3000000);
// ========== 音频配置 ==========
// 开启音频
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "an", 0);
// OpenSL ES 音频输出低延迟
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "opensles", 1);
p.setOnPreparedListener(mp -> {
android.util.Log.d("PlayerActivity", "IJK播放器准备完成开始播放");
mp.start();
});
p.setOnInfoListener((mp, what, extra) -> {
if (what == IMediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) {
android.util.Log.d("PlayerActivity", "IJK首帧渲染完成");
} else if (what == IMediaPlayer.MEDIA_INFO_BUFFERING_START) {
android.util.Log.d("PlayerActivity", "IJK开始缓冲");
} else if (what == IMediaPlayer.MEDIA_INFO_BUFFERING_END) {
android.util.Log.d("PlayerActivity", "IJK缓冲结束");
}
return false;
});
p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> {
android.util.Log.e("PlayerActivity", "IJK播放错误: what=" + what + ", extra=" + extra);
if (ijkFallbackTried || ijkFallbackHlsUrl == null || ijkFallbackHlsUrl.trim().isEmpty()) return true;
ijkFallbackTried = true;
startHls(ijkFallbackHlsUrl, null);
@ -179,8 +284,10 @@ public class PlayerActivity extends AppCompatActivity {
try {
p.setSurface(ijkSurface);
p.setDataSource(url);
android.util.Log.d("PlayerActivity", "IJK开始准备播放: " + url);
p.prepareAsync();
} catch (Exception e) {
android.util.Log.e("PlayerActivity", "IJK播放异常: " + e.getMessage());
if (ijkFallbackHlsUrl != null && !ijkFallbackHlsUrl.trim().isEmpty()) {
startHls(ijkFallbackHlsUrl, null);
}

View File

@ -429,6 +429,12 @@ public class ProfileActivity extends AppCompatActivity {
}
showShareProfileDialog();
});
// 主播中心按钮点击事件
binding.streamerCenterBtn.setOnClickListener(v -> {
StreamerCenterActivity.start(this);
});
binding.addFriendBtn.setOnClickListener(v -> {
// 检查登录状态添加好友需要登录
if (!AuthHelper.requireLogin(this, "添加好友需要登录")) {
@ -480,14 +486,14 @@ public class ProfileActivity extends AppCompatActivity {
PublishWorkActivity.start(this);
});
// 悬浮按钮固定显示
binding.fabPublishWork.setOnClickListener(v -> {
// 检查登录状态发布作品需要登录
if (!AuthHelper.requireLogin(this, "发布作品需要登录")) {
return;
}
PublishWorkActivity.start(this);
});
// 悬浮按钮固定显示- 已隐藏
// binding.fabPublishWork.setOnClickListener(v -> {
// // 检查登录状态发布作品需要登录
// if (!AuthHelper.requireLogin(this, "发布作品需要登录")) {
// return;
// }
// PublishWorkActivity.start(this);
// });
binding.likedGoBrowseBtn.setOnClickListener(v -> startActivity(new Intent(this, MainActivity.class)));
binding.favGoBrowseBtn.setOnClickListener(v -> startActivity(new Intent(this, MainActivity.class)));
binding.profileEditFromTab.setOnClickListener(v -> {
@ -617,9 +623,68 @@ public class ProfileActivity extends AppCompatActivity {
bottomNav.setSelectedItemId(R.id.nav_profile);
// 更新未读消息徽章
UnreadMessageManager.updateBadge(bottomNav);
// 检查主播状态并显示/隐藏主播中心按钮
checkAndUpdateStreamerButton();
}
}
/**
* 检查主播状态并更新主播中心按钮的显示
*/
private void checkAndUpdateStreamerButton() {
// 如果用户未登录隐藏主播中心按钮
if (!AuthHelper.isLoggedIn(this)) {
if (binding.streamerCenterBtn != null) {
binding.streamerCenterBtn.setVisibility(View.GONE);
}
return;
}
// 检查主播资格
ApiClient.getService(getApplicationContext()).checkStreamerStatus()
.enqueue(new Callback<ApiResponse<java.util.Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<java.util.Map<String, Object>>> call,
Response<ApiResponse<java.util.Map<String, Object>>> response) {
if (!response.isSuccessful() || response.body() == null) {
// 接口调用失败隐藏按钮
if (binding.streamerCenterBtn != null) {
binding.streamerCenterBtn.setVisibility(View.GONE);
}
return;
}
ApiResponse<java.util.Map<String, Object>> body = response.body();
if (body.getCode() != 200 || body.getData() == null) {
// 接口返回错误隐藏按钮
if (binding.streamerCenterBtn != null) {
binding.streamerCenterBtn.setVisibility(View.GONE);
}
return;
}
java.util.Map<String, Object> data = body.getData();
Boolean isStreamer = data.get("isStreamer") != null && (Boolean) data.get("isStreamer");
Boolean isBanned = data.get("isBanned") != null && (Boolean) data.get("isBanned");
// 只有认证主播且未被封禁才显示主播中心按钮
if (isStreamer && !isBanned && binding.streamerCenterBtn != null) {
binding.streamerCenterBtn.setVisibility(View.VISIBLE);
} else if (binding.streamerCenterBtn != null) {
binding.streamerCenterBtn.setVisibility(View.GONE);
}
}
@Override
public void onFailure(Call<ApiResponse<java.util.Map<String, Object>>> call, Throwable t) {
// 网络错误隐藏按钮
if (binding.streamerCenterBtn != null) {
binding.streamerCenterBtn.setVisibility(View.GONE);
}
}
});
}
private void loadAndDisplayTags() {
String location = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_LOCATION, "");
String gender = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_GENDER, "");

View File

@ -944,19 +944,38 @@ public class RoomDetailActivity extends AppCompatActivity {
}
// 获取播放地址
// 优先使用 HTTP-FLVIjkPlayer延迟更低2-3秒
// HLS 作为备用延迟 6-10秒
String playUrl = null;
String fallbackHlsUrl = null;
String fallbackUrl = null;
if (r.getStreamUrls() != null) {
// 优先使用HTTP-FLV延迟更低
// 打印流地址信息用于调试
android.util.Log.d("RoomDetail", "流地址信息:");
android.util.Log.d("RoomDetail", " FLV: " + r.getStreamUrls().getFlv());
android.util.Log.d("RoomDetail", " HLS: " + r.getStreamUrls().getHls());
android.util.Log.d("RoomDetail", " RTMP: " + r.getStreamUrls().getRtmp());
// 优先使用 FLV低延迟
playUrl = r.getStreamUrls().getFlv();
fallbackHlsUrl = r.getStreamUrls().getHls();
if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl;
fallbackUrl = r.getStreamUrls().getHls();
// 如果 FLV 不可用使用 HLS
if (TextUtils.isEmpty(playUrl)) {
android.util.Log.w("RoomDetail", "FLV 地址为空,使用 HLS");
playUrl = fallbackUrl;
fallbackUrl = null;
}
} else {
android.util.Log.e("RoomDetail", "streamUrls 为 null");
}
android.util.Log.d("RoomDetail", "最终播放地址: " + playUrl);
android.util.Log.d("RoomDetail", "备用地址: " + fallbackUrl);
if (!TextUtils.isEmpty(playUrl)) {
ensurePlayer(playUrl, fallbackHlsUrl);
ensurePlayer(playUrl, fallbackUrl);
} else {
// 没有播放地址时显示离线状态
android.util.Log.e("RoomDetail", "没有可用的播放地址,显示离线状态");
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
}
@ -1012,15 +1031,15 @@ public class RoomDetailActivity extends AppCompatActivity {
triedAltUrl = false;
hasShownConnectedMessage = false; // 重置连接消息标志
// 优化缓冲配置 - 增大缓冲区以减少卡顿
// HLS 直播通常有 2-3 秒的切片延迟需要足够的缓冲
// 优化缓冲配置 - 针对低延迟 HLS1秒分片
// 平衡延迟和流畅度
androidx.media3.exoplayer.DefaultLoadControl loadControl =
new androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setBufferDurationsMs(
10000, // 最小缓冲 10秒保证流畅播放
30000, // 最大缓冲 30秒足够应对网络波动
5000, // 播放前缓冲 5秒确保有足够数据再开始
10000 // 重新缓冲 10秒卡顿后充分缓冲再继续
2500, // 最小缓冲 2.5秒约2-3个分片
10000, // 最大缓冲 10秒
1500, // 播放前缓冲 1.5秒快速起播
2500 // 重新缓冲 2.5秒
)
.setPrioritizeTimeOverSizeThresholds(true)
.build();
@ -1030,6 +1049,9 @@ public class RoomDetailActivity extends AppCompatActivity {
.setLoadControl(loadControl)
.build();
// 关键设置为直播模式自动跳到最新位置
exo.setPlayWhenReady(true);
// 设置播放器视图
binding.playerView.setPlayer(exo);
binding.playerView.setUseController(true);
@ -1087,6 +1109,12 @@ public class RoomDetailActivity extends AppCompatActivity {
if (playbackState == Player.STATE_READY) {
binding.offlineLayout.setVisibility(View.GONE);
retryCount = 0;
// 关键跳到直播流的最新位置减少延迟
if (exo.isCurrentMediaItemLive()) {
exo.seekToDefaultPosition();
}
// 只显示一次连接消息
if (!hasShownConnectedMessage) {
hasShownConnectedMessage = true;
@ -1106,16 +1134,70 @@ public class RoomDetailActivity extends AppCompatActivity {
}
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
android.util.Log.d("RoomDetail", "开始播放FLV流: " + flvUrl);
android.util.Log.d("RoomDetail", "尝试使用IjkPlayer播放FLV: " + flvUrl);
// 直接使用 HLS 播放避免 IjkPlayer 崩溃问题
// HLS 虽然延迟稍高但稳定性更好
String hlsUrl = fallbackHlsUrl;
if (TextUtils.isEmpty(hlsUrl)) {
hlsUrl = flvUrl.replace(".flv", ".m3u8");
// 先尝试加载 IjkPlayer
ensureIjkLibsLoaded();
// 如果 IjkPlayer 加载失败直接使用 HLS
if (ijkLibLoadFailed) {
android.util.Log.w("RoomDetail", "IjkPlayer 不可用,回退到 HLS 播放");
String hlsUrl = fallbackHlsUrl;
if (TextUtils.isEmpty(hlsUrl)) {
hlsUrl = flvUrl.replace(".flv", ".m3u8");
}
startHls(hlsUrl, null);
return;
}
android.util.Log.d("RoomDetail", "使用IjkPlayer播放FLV低延迟: " + flvUrl);
// 释放之前的播放器
releaseExoPlayer();
releaseIjkPlayer();
// 保存备用地址
ijkUrl = flvUrl;
ijkFallbackHlsUrl = fallbackHlsUrl;
if (TextUtils.isEmpty(ijkFallbackHlsUrl)) {
ijkFallbackHlsUrl = flvUrl.replace(".flv", ".m3u8");
}
ijkFallbackTried = false;
hasShownConnectedMessage = false;
// 显示 FLV 播放视图
if (binding != null) {
binding.playerView.setVisibility(View.GONE);
binding.flvTextureView.setVisibility(View.VISIBLE);
}
// 设置 TextureView 监听
binding.flvTextureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
@Override
public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture surface, int width, int height) {
android.util.Log.d("IjkPlayer", "Surface 可用,开始准备播放");
ijkSurface = new Surface(surface);
prepareIjk(flvUrl);
}
@Override
public void onSurfaceTextureSizeChanged(android.graphics.SurfaceTexture surface, int width, int height) {}
@Override
public boolean onSurfaceTextureDestroyed(android.graphics.SurfaceTexture surface) {
return true;
}
@Override
public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture surface) {}
});
// 如果 Surface 已经可用直接开始播放
if (binding.flvTextureView.isAvailable()) {
android.util.Log.d("IjkPlayer", "Surface 已可用,直接开始播放");
ijkSurface = new Surface(binding.flvTextureView.getSurfaceTexture());
prepareIjk(flvUrl);
}
android.util.Log.d("RoomDetail", "使用 HLS 播放: " + hlsUrl);
startHls(hlsUrl, null);
}
private void prepareIjk(String url) {
@ -1195,13 +1277,18 @@ public class RoomDetailActivity extends AppCompatActivity {
private static boolean ijkLibLoadFailed = false;
private static void ensureIjkLibsLoaded() {
if (ijkLibLoaded || ijkLibLoadFailed) return;
if (ijkLibLoaded || ijkLibLoadFailed) {
android.util.Log.d("IjkPlayer", "IjkPlayer 库状态: loaded=" + ijkLibLoaded + ", failed=" + ijkLibLoadFailed);
return;
}
try {
// 检查设备 CPU 架构
String[] abis = android.os.Build.SUPPORTED_ABIS;
android.util.Log.d("IjkPlayer", "设备 CPU 架构: " + java.util.Arrays.toString(abis));
boolean supported = false;
for (String abi : abis) {
if ("armeabi-v7a".equals(abi) || "arm64-v8a".equals(abi)) {
if ("armeabi-v7a".equals(abi) || "arm64-v8a".equals(abi) || "x86".equals(abi) || "x86_64".equals(abi)) {
supported = true;
break;
}
@ -1217,7 +1304,7 @@ public class RoomDetailActivity extends AppCompatActivity {
ijkLibLoaded = true;
android.util.Log.d("IjkPlayer", "IjkPlayer 库加载成功");
} catch (Throwable e) {
android.util.Log.e("IjkPlayer", "加载IjkPlayer库失败: " + e.getMessage());
android.util.Log.e("IjkPlayer", "加载IjkPlayer库失败: " + e.getMessage(), e);
ijkLibLoadFailed = true;
}
}

View File

@ -107,6 +107,33 @@ public class SettingsPageActivity extends AppCompatActivity {
return;
}
// 处理主播相关点击
if ("主播中心".equals(t)) {
StreamerCenterActivity.start(this);
return;
}
if ("切换到主播模式".equals(t)) {
com.example.livestreaming.net.AuthStore.setStreamerMode(this, true);
Toast.makeText(this, "已切换到主播模式", Toast.LENGTH_SHORT).show();
StreamerCenterActivity.start(this);
refresh();
return;
}
if ("切换回普通用户".equals(t)) {
com.example.livestreaming.net.AuthStore.setStreamerMode(this, false);
Toast.makeText(this, "已切换回普通用户模式", Toast.LENGTH_SHORT).show();
refresh();
return;
}
if ("申请成为主播".equals(t)) {
Intent intent = new Intent(this, StreamerApplyActivity.class);
startActivity(intent);
return;
}
// 处理其他页面的点击事件
if (PAGE_SERVER.equals(page)) {
// 服务器设置页面的其他项目已在上面处理
@ -342,6 +369,22 @@ public class SettingsPageActivity extends AppCompatActivity {
list.add(MoreItem.section("通用"));
list.add(MoreItem.row("服务器设置", "切换API与直播流地址", R.drawable.ic_globe_24));
list.add(MoreItem.row("关于", "版本信息、协议", R.drawable.ic_menu_24));
// 主播相关入口
list.add(MoreItem.section("主播"));
if (com.example.livestreaming.net.AuthStore.isStreamer(this)) {
// 已认证主播
if (com.example.livestreaming.net.AuthStore.isStreamerMode(this)) {
list.add(MoreItem.row("主播中心", "管理直播间、查看数据", R.drawable.ic_live_24));
list.add(MoreItem.row("切换回普通用户", "退出主播模式", R.drawable.ic_person_24));
} else {
list.add(MoreItem.row("切换到主播模式", "进入主播中心", R.drawable.ic_live_24));
}
} else {
// 未认证主播显示申请入口
list.add(MoreItem.row("申请成为主播", "认证后可开播", R.drawable.ic_live_24));
}
return list;
}

View File

@ -0,0 +1,226 @@
package com.example.livestreaming;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.bumptech.glide.Glide;
import com.example.livestreaming.databinding.ActivityStreamerCenterBinding;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.AuthStore;
import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/**
* 主播中心页面
* 显示主播的直播间粉丝收到的礼物等信息
*/
public class StreamerCenterActivity extends AppCompatActivity {
private ActivityStreamerCenterBinding binding;
public static void start(Context context) {
Intent intent = new Intent(context, StreamerCenterActivity.class);
context.startActivity(intent);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityStreamerCenterBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setupToolbar();
setupClickListeners();
loadStreamerData();
}
private void setupToolbar() {
binding.toolbar.setNavigationOnClickListener(v -> finish());
}
private void setupClickListeners() {
// 开始直播
binding.btnStartLive.setOnClickListener(v -> {
Intent intent = new Intent(this, BroadcastActivity.class);
startActivity(intent);
});
// 粉丝列表
binding.layoutFans.setOnClickListener(v -> {
Intent intent = new Intent(this, FansListActivity.class);
startActivity(intent);
});
// 获赞列表
binding.layoutLikes.setOnClickListener(v -> {
Intent intent = new Intent(this, LikesListActivity.class);
startActivity(intent);
});
// 礼物列表
binding.layoutGifts.setOnClickListener(v -> {
// TODO: 跳转到礼物详情页
Toast.makeText(this, "礼物详情功能开发中", Toast.LENGTH_SHORT).show();
});
// 收益
binding.layoutIncome.setOnClickListener(v -> {
// TODO: 跳转到收益页面
Toast.makeText(this, "收益详情功能开发中", Toast.LENGTH_SHORT).show();
});
// 查看全部礼物
binding.tvViewAllGifts.setOnClickListener(v -> {
// TODO: 跳转到礼物列表页
Toast.makeText(this, "礼物列表功能开发中", Toast.LENGTH_SHORT).show();
});
// 我的直播间
binding.cardMyRoom.setOnClickListener(v -> {
// TODO: 跳转到直播间管理页
Toast.makeText(this, "直播间管理功能开发中", Toast.LENGTH_SHORT).show();
});
// 切换回普通用户
binding.btnSwitchToUser.setOnClickListener(v -> {
AuthStore.setStreamerMode(this, false);
Toast.makeText(this, "已切换回普通用户模式", Toast.LENGTH_SHORT).show();
finish();
});
}
private void loadStreamerData() {
binding.progressBar.setVisibility(View.VISIBLE);
// 设置基本信息
String nickname = AuthStore.getNickname(this);
binding.tvNickname.setText(nickname);
// 加载主播数据
loadStreamerStats();
loadReceivedGifts();
}
private void loadStreamerStats() {
ApiClient.getService(this).getStreamerStats()
.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
Response<ApiResponse<Map<String, Object>>> response) {
binding.progressBar.setVisibility(View.GONE);
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
Map<String, Object> data = response.body().getData();
updateStats(data);
}
}
@Override
public void onFailure(Call<ApiResponse<Map<String, Object>>> call, Throwable t) {
binding.progressBar.setVisibility(View.GONE);
}
});
}
private void updateStats(Map<String, Object> data) {
if (data == null) return;
// 粉丝数
Object fansObj = data.get("fansCount");
if (fansObj != null) {
binding.tvFansCount.setText(formatCount(((Number) fansObj).intValue()));
}
// 获赞数
Object likesObj = data.get("likesCount");
if (likesObj != null) {
binding.tvLikesCount.setText(formatCount(((Number) likesObj).intValue()));
}
// 礼物数
Object giftsObj = data.get("giftsCount");
if (giftsObj != null) {
binding.tvGiftsCount.setText(formatCount(((Number) giftsObj).intValue()));
}
// 收益
Object incomeObj = data.get("totalIncome");
if (incomeObj != null) {
binding.tvIncomeCount.setText(formatCount(((Number) incomeObj).intValue()));
}
// 主播等级
Object levelObj = data.get("streamerLevel");
if (levelObj != null) {
binding.tvStreamerLevel.setText("主播等级: Lv." + ((Number) levelObj).intValue());
}
// 头像
Object avatarObj = data.get("avatar");
if (avatarObj != null && !avatarObj.toString().isEmpty()) {
Glide.with(this)
.load(avatarObj.toString())
.placeholder(R.drawable.ic_person_24)
.into(binding.ivAvatar);
}
// 直播间状态
Object roomStatusObj = data.get("hasActiveRoom");
if (roomStatusObj != null && (Boolean) roomStatusObj) {
binding.tvRoomStatus.setText("直播间已创建");
} else {
binding.tvRoomStatus.setText("暂无直播间,点击开始直播创建");
}
}
private void loadReceivedGifts() {
ApiClient.getService(this).getReceivedGifts(1, 10)
.enqueue(new Callback<ApiResponse<List<Map<String, Object>>>>() {
@Override
public void onResponse(Call<ApiResponse<List<Map<String, Object>>>> call,
Response<ApiResponse<List<Map<String, Object>>>> response) {
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
List<Map<String, Object>> gifts = response.body().getData();
if (gifts != null && !gifts.isEmpty()) {
binding.tvNoGifts.setVisibility(View.GONE);
binding.rvGifts.setVisibility(View.VISIBLE);
// TODO: 设置礼物列表适配器
} else {
binding.tvNoGifts.setVisibility(View.VISIBLE);
binding.rvGifts.setVisibility(View.GONE);
}
} else {
binding.tvNoGifts.setVisibility(View.VISIBLE);
binding.rvGifts.setVisibility(View.GONE);
}
}
@Override
public void onFailure(Call<ApiResponse<List<Map<String, Object>>>> call, Throwable t) {
binding.tvNoGifts.setVisibility(View.VISIBLE);
binding.rvGifts.setVisibility(View.GONE);
}
});
}
private String formatCount(int count) {
if (count >= 10000) {
return String.format("%.1fw", count / 10000.0);
} else if (count >= 1000) {
return String.format("%.1fk", count / 1000.0);
}
return String.valueOf(count);
}
}

View File

@ -517,6 +517,26 @@ public interface ApiService {
@GET("api/front/streamer/applications")
Call<ApiResponse<List<Map<String, Object>>>> getStreamerApplications();
/**
* 获取主播统计数据粉丝数获赞数礼物数收益等
*/
@GET("api/front/streamer/stats")
Call<ApiResponse<Map<String, Object>>> getStreamerStats();
/**
* 获取收到的礼物列表
*/
@GET("api/front/streamer/gifts/received")
Call<ApiResponse<List<Map<String, Object>>>> getReceivedGifts(
@Query("page") int page,
@Query("pageSize") int pageSize);
/**
* 获取我的直播间列表
*/
@GET("api/front/streamer/rooms")
Call<ApiResponse<List<Room>>> getMyRooms();
// ==================== 社区/缘池接口 ====================
/**

View File

@ -48,6 +48,8 @@ public final class AuthStore {
private static final String KEY_USER_ID = "user_id";
private static final String KEY_NICKNAME = "nickname";
private static final String KEY_IS_STREAMER = "is_streamer";
private static final String KEY_STREAMER_MODE = "streamer_mode";
public static void setUserInfo(Context context, @Nullable String userId, @Nullable String nickname) {
if (context == null) return;
@ -79,4 +81,58 @@ public final class AuthStore {
.getString(KEY_NICKNAME, null);
return nickname != null ? nickname : "用户";
}
/**
* 设置用户是否是认证主播
*/
public static void setIsStreamer(Context context, boolean isStreamer) {
if (context == null) return;
Log.d(TAG, "setIsStreamer: " + isStreamer);
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_IS_STREAMER, isStreamer)
.apply();
}
/**
* 获取用户是否是认证主播
*/
public static boolean isStreamer(Context context) {
if (context == null) return false;
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_IS_STREAMER, false);
}
/**
* 设置是否处于主播模式
*/
public static void setStreamerMode(Context context, boolean streamerMode) {
if (context == null) return;
Log.d(TAG, "setStreamerMode: " + streamerMode);
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_STREAMER_MODE, streamerMode)
.apply();
}
/**
* 获取是否处于主播模式
*/
public static boolean isStreamerMode(Context context) {
if (context == null) return false;
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_STREAMER_MODE, false);
}
/**
* 清除所有用户数据退出登录时调用
*/
public static void clearAll(Context context) {
if (context == null) return;
Log.d(TAG, "clearAll: clearing all auth data");
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.clear()
.apply();
}
}

View File

@ -2,16 +2,44 @@ package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
/**
* 创建直播间请求
* 对应后端 CreateLiveRoomRequest
*/
public class CreateRoomRequest {
@SerializedName("title")
private final String title;
private String title;
@SerializedName("streamerName")
private final String streamerName;
private String streamerName;
@SerializedName("type")
private final String type;
private String type;
@SerializedName("categoryId")
private Integer categoryId;
@SerializedName("description")
private String description;
@SerializedName("coverImage")
private String coverImage;
@SerializedName("tags")
private String tags;
@SerializedName("notice")
private String notice;
public CreateRoomRequest() {
}
public CreateRoomRequest(String title, String streamerName) {
this.title = title;
this.streamerName = streamerName;
this.type = "live";
}
public CreateRoomRequest(String title, String streamerName, String type) {
this.title = title;
@ -19,15 +47,68 @@ public class CreateRoomRequest {
this.type = type;
}
// Getters and Setters
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getStreamerName() {
return streamerName;
}
public void setStreamerName(String streamerName) {
this.streamerName = streamerName;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public Integer getCategoryId() {
return categoryId;
}
public void setCategoryId(Integer categoryId) {
this.categoryId = categoryId;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getCoverImage() {
return coverImage;
}
public void setCoverImage(String coverImage) {
this.coverImage = coverImage;
}
public String getTags() {
return tags;
}
public void setTags(String tags) {
this.tags = tags;
}
public String getNotice() {
return notice;
}
public void setNotice(String notice) {
this.notice = notice;
}
}

View File

@ -3,4 +3,3 @@
android:shape="oval">
<solid android:color="#FF4444" />
</shape>

View File

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

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="90"
android:startColor="#99000000"
android:endColor="#00000000" />
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:startColor="#99000000"
android:endColor="#00000000" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/purple_500" />
<corners android:radius="12dp" />
</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="#66000000" />
<corners android:radius="20dp" />
</shape>

View File

@ -5,7 +5,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#666666"
android:fillColor="#FFFFFF"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF5252"
android:pathData="M12,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0" />
<path
android:fillColor="#666666"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
<path
android:fillColor="#666666"
android:pathData="M12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z" />
</vector>

View File

@ -5,6 +5,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#777777"
android:pathData="M12,12a4,4 0,1 0,-4 -4a4,4 0,0 0,4 4zm0,2c-4.42,0 -8,2.24 -8,5v1h16v-1c0,-2.76 -3.58,-5 -8,-5z" />
android:fillColor="#FFFFFF"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" />
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M19.14,12.94c0.04,-0.31 0.06,-0.63 0.06,-0.94c0,-0.31 -0.02,-0.63 -0.06,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.37 4.8,11.69 4.8,12s0.02,0.63 0.06,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M9,12c0,1.66 1.34,3 3,3s3,-1.34 3,-3s-1.34,-3 -3,-3S9,10.34 9,12zM7,7V4H5v3H2v2h3v3h2V9h3V7H7zM17,17v3h2v-3h3v-2h-3v-3h-2v3h-3v2H17z" />
</vector>

View File

@ -0,0 +1,183 @@
<?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="#000000">
<!-- 使用 SurfaceView 作为摄像头预览,支持 RootEncoder RTMP 推流 -->
<SurfaceView
android:id="@+id/surfaceView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 顶部工具栏 -->
<LinearLayout
android:id="@+id/topBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_gradient_top"
android:orientation="horizontal"
android:padding="16dp"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/btnClose"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_circle_semi_transparent"
android:contentDescription="关闭"
android:src="@drawable/ic_close_24"
app:tint="@android:color/white" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageButton
android:id="@+id/btnSwitchCamera"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="12dp"
android:background="@drawable/bg_circle_semi_transparent"
android:contentDescription="切换摄像头"
android:src="@drawable/ic_switch_camera_24"
app:tint="@android:color/white" />
<ImageButton
android:id="@+id/btnSettings"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/bg_circle_semi_transparent"
android:contentDescription="设置"
android:src="@drawable/ic_settings_24"
app:tint="@android:color/white" />
</LinearLayout>
<!-- 直播信息卡片(开播前显示) -->
<androidx.cardview.widget.CardView
android:id="@+id/cardLiveInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:cardBackgroundColor="#CC000000"
app:cardCornerRadius="12dp"
app:layout_constraintBottom_toTopOf="@id/bottomBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="直播标题"
app:boxBackgroundColor="#33FFFFFF"
app:hintTextColor="@android:color/white">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLength="30"
android:textColor="@android:color/white" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- 直播状态信息(开播后显示) -->
<LinearLayout
android:id="@+id/liveStatusBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="80dp"
android:background="@drawable/bg_rounded_semi_transparent"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<View
android:layout_width="8dp"
android:layout_height="8dp"
android:background="@drawable/bg_circle_red" />
<TextView
android:id="@+id/tvLiveTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="00:00:00"
android:textColor="@android:color/white"
android:textSize="14sp" />
<TextView
android:id="@+id/tvViewerCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:drawableStart="@drawable/ic_person_24"
android:drawablePadding="4dp"
android:text="0"
android:textColor="@android:color/white"
android:textSize="14sp" />
</LinearLayout>
<!-- 底部控制栏 -->
<LinearLayout
android:id="@+id/bottomBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_gradient_bottom"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp"
app:layout_constraintBottom_toBottomOf="parent">
<!-- 开播按钮 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnStartLive"
android:layout_width="200dp"
android:layout_height="56dp"
android:text="开始直播"
android:textSize="18sp"
app:cornerRadius="28dp"
app:backgroundTint="#FF4444" />
<!-- 停止直播按钮(开播后显示) -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnStopLive"
android:layout_width="200dp"
android:layout_height="56dp"
android:text="结束直播"
android:textSize="18sp"
android:visibility="gone"
app:cornerRadius="28dp"
app:backgroundTint="#666666" />
</LinearLayout>
<!-- 加载指示器 -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="48dp"
android:layout_height="48dp"
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

@ -221,6 +221,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
@ -549,10 +550,24 @@
android:gravity="center"
android:text="分享主页"
android:textColor="#111111"
app:layout_constraintEnd_toStartOf="@id/addFriendBtn"
app:layout_constraintEnd_toStartOf="@id/streamerCenterBtn"
app:layout_constraintStart_toEndOf="@id/editProfile"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/streamerCenterBtn"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_marginStart="12dp"
android:background="@drawable/bg_purple_button"
android:gravity="center"
android:text="主播中心"
android:textColor="#FFFFFF"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/addFriendBtn"
app:layout_constraintStart_toEndOf="@id/shareHome"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/addFriendBtn"
android:layout_width="44dp"
@ -563,7 +578,7 @@
android:src="@drawable/ic_person_24"
android:tint="@color/purple_500"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/shareHome"
app:layout_constraintStart_toEndOf="@id/streamerCenterBtn"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@ -573,6 +588,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:visibility="gone"
app:tabIndicatorColor="@color/purple_500"
app:tabIndicatorFullWidth="false"
app:tabIndicatorHeight="3dp"
@ -605,6 +621,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/profileTabs">

View File

@ -0,0 +1,362 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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="@color/background_color">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
app:elevation="0dp">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_arrow_back_24"
app:title="主播中心"
app:titleTextColor="@color/text_primary" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 主播信息卡片 -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/ivAvatar"
android:layout_width="64dp"
android:layout_height="64dp"
android:src="@drawable/ic_person_24" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvNickname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="主播昵称"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvStreamerLevel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="主播等级: Lv.1"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnStartLive"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="开始直播"
app:cornerRadius="20dp" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 数据统计卡片 -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="数据统计"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/layoutFans"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/tvFansCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="粉丝"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/layoutLikes"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/tvLikesCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="获赞"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/layoutGifts"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/tvGiftsCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="礼物"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/layoutIncome"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/tvIncomeCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="收益"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 我的直播间 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardMyRoom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="我的直播间"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvRoomStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="暂无直播间"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 收到的礼物 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardReceivedGifts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="收到的礼物"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvViewAllGifts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="查看全部 >"
android:textColor="@color/primary"
android:textSize="14sp" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvGifts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal" />
<TextView
android:id="@+id/tvNoGifts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:text="暂无收到的礼物"
android:textColor="@color/text_hint"
android:textSize="14sp"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 切换回普通用户 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSwitchToUser"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="切换回普通用户"
app:cornerRadius="8dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

11
delete_room_menus.sql Normal file
View File

@ -0,0 +1,11 @@
-- 删除"房间类型"和"房间背景"菜单
-- 先查询表结构
-- DESCRIBE eb_system_menu;
-- 查询这两个菜单
SELECT id, name FROM eb_system_menu WHERE name IN ('房间类型', '房间背景');
-- 删除菜单(根据名称)
DELETE FROM eb_system_menu WHERE name = '房间类型';
DELETE FROM eb_system_menu WHERE name = '房间背景';

View File

@ -38,15 +38,20 @@ vhost __defaultVhost__ {
chunk_size 4096;
}
# HLS 配置 - 优化延迟
# HLS 配置 - 低延迟模式
hls {
enabled on;
hls_path ./objs/nginx/html;
# 减少分片时长,降低延迟
hls_fragment 2;
hls_window 6;
# 启用低延迟模式
hls_dispose 30;
# 最小分片时长 1秒降低延迟
hls_fragment 1;
# 保留 3 个分片
hls_window 3;
# 快速清理过期分片
hls_dispose 10;
# 启用 ts 文件清理
hls_cleanup on;
# 等待关键帧
hls_wait_keyframe on;
}
# HTTP-FLV 配置 - 低延迟播放
@ -64,12 +69,14 @@ vhost __defaultVhost__ {
# 播放配置 - 优化延迟
play {
# 减少GOP缓存
# 关闭 GOP 缓存,降低延迟
gop_cache off;
# 启用时间校正
time_jitter full;
# 减少队列长度
queue_length 10;
# 降低首帧等待
mw_latency 100;
}
# 发布配置 - 优化延迟

View File

@ -0,0 +1,54 @@
docker stop srs-server
docker rm srs-server
cat > /opt/live-streaming/docker/srs/srs.conf << 'EOF'
listen 1935;
max_connections 1000;
daemon off;
srs_log_tank console;
http_server {
enabled on;
listen 8080;
dir ./objs/nginx/html;
crossdomain on;
}
http_api {
enabled on;
listen 1985;
crossdomain on;
}
vhost __defaultVhost__ {
# HLS 作为备用
hls {
enabled on;
hls_path ./objs/nginx/html;
hls_fragment 2;
hls_window 4;
hls_cleanup on;
}
# HTTP-FLV 低延迟(主要使用)
http_remux {
enabled on;
mount [vhost]/[app]/[stream].flv;
}
# 低延迟播放配置
play {
gop_cache on;
queue_length 10;
mw_latency 100;
}
}
EOF
docker run -d --name srs-server \
-p 25002:1935 \
-p 25003:8080 \
-p 1985:1985 \
-v /opt/live-streaming/docker/srs/srs.conf:/usr/local/srs/conf/srs.conf \
--restart unless-stopped \
ossrs/srs:5

22
room_category_table.sql Normal file
View File

@ -0,0 +1,22 @@
-- 直播间分类表
CREATE TABLE IF NOT EXISTS `eb_live_room_category` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '分类ID',
`name` varchar(50) NOT NULL COMMENT '分类名称',
`icon` varchar(255) DEFAULT NULL COMMENT '图标类名或URL',
`sort` int(11) DEFAULT 0 COMMENT '排序',
`status` tinyint(1) DEFAULT 1 COMMENT '状态0-禁用1-启用',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='直播间分类表';
-- 初始化一些默认分类
INSERT INTO `eb_live_room_category` (`name`, `icon`, `sort`, `status`) VALUES
('娱乐', 'el-icon-video-camera', 1, 1),
('游戏', 'el-icon-coordinate', 2, 1),
('音乐', 'el-icon-headset', 3, 1),
('户外', 'el-icon-location', 4, 1),
('聊天', 'el-icon-chat-dot-round', 5, 1);
-- 给 eb_live_room 表添加 category_id 字段(如果不存在)
-- ALTER TABLE `eb_live_room` ADD COLUMN `category_id` int(11) DEFAULT NULL COMMENT '分类ID' AFTER `streamer_name`;

228
服务管理指南.md Normal file
View File

@ -0,0 +1,228 @@
# 直播系统服务管理指南
## 📍 服务器信息
- **IP地址**: 1.15.149.240
- **SSH登录**: `ssh root@1.15.149.240`
---
## 🗂️ 服务清单
| 服务 | 端口 | 部署位置 | 说明 |
|------|------|----------|------|
| Admin API | 30003 | /opt/zhibo/admin-api | 管理后台API |
| Front API | 8083 | /opt/zhibo/front-api | APP端API |
| Admin Web | 30002 | /opt/zhibo/admin-web | 管理后台网页 |
| SRS 直播 | 25002/25003 | Docker | RTMP推流/HTTP拉流 |
| TURN 服务 | 3478 | Docker | WebRTC中继 |
---
## 🔧 一、Java 后端服务管理
### 登录服务器
```bash
ssh root@1.15.149.240
```
### 查看服务状态
```bash
# 查看所有Java进程
ps aux | grep -E "Crmeb-admin|Crmeb-front"
# 查看端口占用
netstat -tlnp | grep -E "30003|8083"
```
### 停止服务
```bash
# 停止 Admin API
pkill -f "Crmeb-admin.jar"
# 停止 Front API
pkill -f "Crmeb-front.jar"
# 或者一键停止
/opt/zhibo/scripts/stop-all.sh
```
### 启动服务
```bash
# 一键启动所有服务
/opt/zhibo/scripts/start-all.sh
# 或者单独启动
/opt/zhibo/scripts/start-admin-api.sh
/opt/zhibo/scripts/start-front-api.sh
```
### 查看日志
```bash
# Admin API 日志
tail -100f /opt/zhibo/logs/admin-api.log
# Front API 日志
tail -100f /opt/zhibo/logs/front-api.log
```
---
## 🎬 二、SRS 直播服务管理
### 查看 SRS 状态
```bash
# 查看 Docker 容器状态
docker ps | grep srs
# 查看 SRS 日志
docker logs srs-server --tail 100
```
### 停止 SRS
```bash
# 方法1: 使用 docker-compose如果有
cd /opt/live-streaming # 或者 SRS 部署目录
docker-compose down
# 方法2: 直接停止容器
docker stop srs-server
docker rm srs-server
```
### 启动 SRS
```bash
# 方法1: 使用 docker-compose
cd /opt/live-streaming
docker-compose up -d
# 方法2: 直接运行
docker run -d --name srs-server \
-p 25002:1935 \
-p 25003:8080 \
-p 1985:1985 \
-v /opt/live-streaming/docker/srs/srs.conf:/usr/local/srs/conf/srs.conf \
ossrs/srs:5
```
### 重启 SRS应用新配置
```bash
docker restart srs-server
```
---
## 🔄 三、完整重新部署流程
### 步骤 1: 停止所有服务
```bash
ssh root@1.15.149.240
# 停止 Java 服务
pkill -f "Crmeb-admin.jar"
pkill -f "Crmeb-front.jar"
# 停止 SRS
docker stop srs-server 2>/dev/null
docker rm srs-server 2>/dev/null
```
### 步骤 2: 上传新文件(在本地执行)
```bash
# 上传 Java JAR 包
scp Zhibo/zhibo-h/crmeb-admin/target/Crmeb-admin.jar root@1.15.149.240:/opt/zhibo/admin-api/
scp Zhibo/zhibo-h/crmeb-front/target/Crmeb-front.jar root@1.15.149.240:/opt/zhibo/front-api/
# 上传 SRS 配置
scp live-streaming/docker/srs/srs.conf root@1.15.149.240:/opt/live-streaming/docker/srs/
```
### 步骤 3: 启动所有服务
```bash
ssh root@1.15.149.240
# 启动 Java 服务
/opt/zhibo/scripts/start-all.sh
# 启动 SRS
cd /opt/live-streaming
docker-compose up -d
```
### 步骤 4: 验证服务
```bash
# 检查端口
netstat -tlnp | grep -E "30003|8083|25002|25003"
# 测试 API
curl http://localhost:8083/api/front/index
curl http://localhost:30003/api/admin/version
# 测试 SRS
curl http://localhost:1985/api/v1/versions
```
---
## 🚨 四、常见问题排查
### 服务启动失败
```bash
# 查看详细日志
cat /opt/zhibo/logs/admin-api.log
cat /opt/zhibo/logs/front-api.log
# 检查 Java 版本
java -version
# 检查 Redis 是否运行
redis-cli ping
```
### 端口被占用
```bash
# 查看端口占用
lsof -i :8083
lsof -i :30003
# 杀掉占用进程
kill -9 <PID>
```
### SRS 无法推流
```bash
# 检查防火墙
firewall-cmd --list-ports
# 开放端口
firewall-cmd --add-port=25002/tcp --permanent
firewall-cmd --add-port=25003/tcp --permanent
firewall-cmd --reload
```
---
## 📱 五、Android APP 配置
APP 连接的服务器地址配置在 `android-app/local.properties`:
```properties
api.base_url_emulator=http://1.15.149.240:8083/
api.base_url_device=http://1.15.149.240:8083/
live.server_host=1.15.149.240
live.server_port=8083
```
修改后需要重新编译 APK。
---
## 📋 六、快速命令参考
| 操作 | 命令 |
|------|------|
| 登录服务器 | `ssh root@1.15.149.240` |
| 查看所有服务 | `ps aux \| grep -E "Crmeb\|srs"` |
| 停止所有 Java | `pkill -f "Crmeb"` |
| 启动所有 Java | `/opt/zhibo/scripts/start-all.sh` |
| 查看 SRS 日志 | `docker logs srs-server --tail 50` |
| 重启 SRS | `docker restart srs-server` |
| 查看端口 | `netstat -tlnp \| grep -E "8083\|30003\|25002\|25003"` |